smoonb 1.0.13 → 1.0.14

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/package.json CHANGED
@@ -1,71 +1,71 @@
1
- {
2
- "name": "smoonb",
3
- "version": "1.0.13",
4
- "description": "Complete Supabase backup and migration tool. https://www.smoonb.com/#price",
5
- "preferGlobal": false,
6
- "preventGlobalInstall": true,
7
- "main": "index.js",
8
- "bin": {
9
- "smoonb": "bin/smoonb.js"
10
- },
11
- "scripts": {
12
- "test": "node --test tests/*.test.js",
13
- "start": "node bin/smoonb.js",
14
- "preinstall": "node -e \"if(process.env.npm_config_global) { console.error('\\n❌ SMOONB NÃO DEVE SER INSTALADO GLOBALMENTE!\\n\\n📋 Para usar o smoonb, instale localmente no seu projeto:\\n npm install smoonb\\n\\n💡 Depois execute com:\\n npx smoonb backup\\n\\n🚫 Instalação global cancelada!\\n'); process.exit(1); }\"",
15
- "postinstall": "echo '\\n✅ smoonb instalado com sucesso!\\n💡 Execute: npx smoonb backup\\n📖 Documentação: https://github.com/almmello/smoonb\\n'",
16
- "lint": "eslint . --ext .js",
17
- "lint:fix": "eslint . --ext .js --fix",
18
- "build": "node scripts/build.js",
19
- "build:check": "node scripts/build.js",
20
- "prepublishOnly": "npm test && npm run lint && npm run build",
21
- "test:docker": "node tests/integration/circular-restore.js",
22
- "test:restore:docker": "node tests/integration/verified-restore.js",
23
- "test:backup:docker": "node tests/integration/backup-isolation.js"
24
- },
25
- "keywords": [
26
- "supabase",
27
- "backup",
28
- "migration",
29
- "postgresql",
30
- "edge-functions",
31
- "database",
32
- "cli",
33
- "tool"
34
- ],
35
- "author": "Goalmoon Tecnologia LTDA <https://www.smoonb.com>",
36
- "license": "SEE LICENSE IN LICENSE.md",
37
- "engines": {
38
- "node": "^22.0.0 || ^24.0.0 || ^26.0.0"
39
- },
40
- "dependencies": {
41
- "@supabase/supabase-js": "^2.93.0",
42
- "adm-zip": "0.6.1",
43
- "chalk": "^4.1.2",
44
- "commander": "^11.1.0",
45
- "env-paths": "^4.0.0",
46
- "inquirer": "^8.2.7",
47
- "supabase": "2.122.1"
48
- },
49
- "type": "commonjs",
50
- "devDependencies": {
51
- "eslint": "^9.38.0"
52
- },
53
- "repository": {
54
- "type": "git",
55
- "url": "git+https://github.com/almmello/smoonb.git"
56
- },
57
- "bugs": {
58
- "url": "https://github.com/almmello/smoonb/issues"
59
- },
60
- "homepage": "https://www.smoonb.com",
61
- "files": [
62
- "bin/",
63
- "src/",
64
- "README.md",
65
- "README.pt-BR.md",
66
- "LICENSE",
67
- "LICENSE.pt-BR.md",
68
- "CHANGELOG.md",
69
- "npm-shrinkwrap.json"
70
- ]
71
- }
1
+ {
2
+ "name": "smoonb",
3
+ "version": "1.0.14",
4
+ "description": "Complete Supabase backup and migration tool. https://www.smoonb.com/#price",
5
+ "preferGlobal": false,
6
+ "preventGlobalInstall": true,
7
+ "main": "index.js",
8
+ "bin": {
9
+ "smoonb": "bin/smoonb.js"
10
+ },
11
+ "scripts": {
12
+ "test": "node --test tests/*.test.js",
13
+ "start": "node bin/smoonb.js",
14
+ "preinstall": "node -e \"if(process.env.npm_config_global) { console.error('\\n❌ SMOONB NÃO DEVE SER INSTALADO GLOBALMENTE!\\n\\n📋 Para usar o smoonb, instale localmente no seu projeto:\\n npm install smoonb\\n\\n💡 Depois execute com:\\n npx smoonb backup\\n\\n🚫 Instalação global cancelada!\\n'); process.exit(1); }\"",
15
+ "postinstall": "echo '\\n✅ smoonb instalado com sucesso!\\n💡 Execute: npx smoonb backup\\n📖 Documentação: https://github.com/almmello/smoonb\\n'",
16
+ "lint": "eslint . --ext .js",
17
+ "lint:fix": "eslint . --ext .js --fix",
18
+ "build": "node scripts/build.js",
19
+ "build:check": "node scripts/build.js",
20
+ "prepublishOnly": "npm test && npm run lint && npm run build",
21
+ "test:docker": "node tests/integration/circular-restore.js",
22
+ "test:restore:docker": "node tests/integration/verified-restore.js",
23
+ "test:backup:docker": "node tests/integration/backup-isolation.js"
24
+ },
25
+ "keywords": [
26
+ "supabase",
27
+ "backup",
28
+ "migration",
29
+ "postgresql",
30
+ "edge-functions",
31
+ "database",
32
+ "cli",
33
+ "tool"
34
+ ],
35
+ "author": "Goalmoon Tecnologia LTDA <https://www.smoonb.com>",
36
+ "license": "SEE LICENSE IN LICENSE.md",
37
+ "engines": {
38
+ "node": "^22.0.0 || ^24.0.0 || ^26.0.0"
39
+ },
40
+ "dependencies": {
41
+ "@supabase/supabase-js": "^2.93.0",
42
+ "adm-zip": "0.6.1",
43
+ "chalk": "^4.1.2",
44
+ "commander": "^11.1.0",
45
+ "env-paths": "^4.0.0",
46
+ "inquirer": "^8.2.7",
47
+ "supabase": "2.122.1"
48
+ },
49
+ "type": "commonjs",
50
+ "devDependencies": {
51
+ "eslint": "^9.38.0"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/almmello/smoonb.git"
56
+ },
57
+ "bugs": {
58
+ "url": "https://github.com/almmello/smoonb/issues"
59
+ },
60
+ "homepage": "https://www.smoonb.com",
61
+ "files": [
62
+ "bin/",
63
+ "src/",
64
+ "README.md",
65
+ "README.pt-BR.md",
66
+ "LICENSE",
67
+ "LICENSE.pt-BR.md",
68
+ "CHANGELOG.md",
69
+ "npm-shrinkwrap.json"
70
+ ]
71
+ }
@@ -4,7 +4,7 @@ const fs = require('fs').promises;
4
4
  const { ensureDir, writeJson } = require('../../utils/fsx');
5
5
  const { showBetaBanner } = require('../../utils/banner');
6
6
  const { getDockerVersion } = require('../../utils/docker');
7
- const { readEnvFile, backupEnvFile } = require('../../utils/env');
7
+ const { readEnvFile, writeEnvFile, backupEnvFile } = require('../../utils/env');
8
8
  const { saveEnvMap } = require('../../utils/envMap');
9
9
  const { mapEnvVariablesInteractively, askComponentsFlags, printSupabasePostgresMajorHintOnce } = require('../../interactive/envMapper');
10
10
  const { confirm } = require('../../utils/prompt');
@@ -85,7 +85,7 @@ module.exports = async (options) => {
85
85
  await ensureDir(backupDir);
86
86
 
87
87
  // Backup e mapeamento do .env.local
88
- const envBackupPath = path.join(backupDir, 'env', '.env.local');
88
+ const envBackupPath = path.join(backupDir, 'env', '.env.local.before');
89
89
  await ensureDir(path.dirname(envBackupPath));
90
90
 
91
91
  // Verificar se o arquivo existe antes de fazer backup
@@ -93,7 +93,8 @@ module.exports = async (options) => {
93
93
  await fs.access(envPath);
94
94
  await backupEnvFile(envPath, envBackupPath);
95
95
  console.log(chalk.blue(`📁 ${getT('env.mapping.backupCreated', { path: path.relative(process.cwd(), envBackupPath) })}`));
96
- } catch {
96
+ } catch (error) {
97
+ if (error.code !== 'ENOENT') throw error;
97
98
  // Arquivo não existe, não fazer backup
98
99
  console.log(chalk.yellow(`⚠️ ${getT('env.mapping.fileNotFound')}`));
99
100
  }
@@ -110,10 +111,13 @@ module.exports = async (options) => {
110
111
  'SMOONB_TELEMETRY_ENABLED'
111
112
  ];
112
113
  const currentEnv = await readEnvFile(envPath);
113
- const { finalEnv, dePara } = await mapEnvVariablesInteractively(currentEnv, expectedKeys);
114
- // Mapping applies to this run only.
114
+ const { finalEnv, dePara } = await mapEnvVariablesInteractively(currentEnv, expectedKeys, async (entry, mapping) => {
115
+ await writeEnvFile(envPath, entry);
116
+ await saveEnvMap(mapping, path.join(backupDir, 'env', 'env-map.json'));
117
+ });
118
+ await backupEnvFile(envPath, path.join(backupDir, 'env', '.env.local'));
115
119
  await saveEnvMap(dePara, path.join(backupDir, 'env', 'env-map.json'));
116
- console.log(chalk.green(`✅ ${getT('backup.environmentPreserved')}`));
120
+ console.log(chalk.green(`✅ ${getT('backup.environmentSaved')}`));
117
121
 
118
122
  function getValue(expectedKey) {
119
123
  const clientKey = Object.keys(dePara).find(k => dePara[k] === expectedKey);
@@ -1,7 +1,7 @@
1
1
  const https = require('https');
2
2
  const inquirer = require('inquirer');
3
3
  const path = require('path');
4
- const { readEnvFile } = require('../../../utils/env');
4
+ const { readEnvFile, writeEnvFile } = require('../../../utils/env');
5
5
  const { APP_CONFIG } = require('../../../config/appConfig');
6
6
  const { t } = require('../../../i18n');
7
7
  const ui = require('../../../utils/cliUi');
@@ -271,7 +271,7 @@ module.exports = async (options) => {
271
271
  ui.link(` ${appUrl}`);
272
272
  process.exit(1);
273
273
  }
274
- // Keep interactive credentials in memory; preserve the project's environment file.
274
+ await writeEnvFile(envPath, { [LICENSE_KEY_ENV]: licenseKey });
275
275
  process.env[LICENSE_KEY_ENV] = licenseKey;
276
276
  }
277
277
 
@@ -155,8 +155,8 @@
155
155
  "disclaimer.operationCancelled": "Operation cancelled by user.",
156
156
 
157
157
  "consent.title": "Smoonb will read .env.local and preserve the project file.",
158
- "consent.backup": "A copy of .env.local will be saved inside the backup.",
159
- "consent.mapping": "We will map your environment variables to ensure all required keys are present and have the correct values for the target project.",
158
+ "consent.backup": "The previous configuration and the configuration used will be copied to the backup env folder.",
159
+ "consent.mapping": "Each confirmed value will be saved immediately to .env.local, before backup. Other variables will be preserved, and saved values will remain available if execution fails or is canceled.",
160
160
  "consent.proceed": "Do you consent to proceed?",
161
161
 
162
162
  "help.configuration": "CONFIGURATION:",
@@ -821,5 +821,5 @@
821
821
  "error.storageCredentialsNotConfigured": "Target project credentials not configured. SUPABASE_PROJECT_ID and SUPABASE_ACCESS_TOKEN are required",
822
822
  "error.supabaseCredentialsNotConfigured": "Supabase credentials not configured. NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required",
823
823
  "restore.verified.remapUrls": "Update old Supabase service URLs to the new project in data and files? (Includes URLs inside JSON; unrelated text and IDs are preserved.)",
824
- "backup.environmentPreserved": "Environment mapped for this run; project file preserved."
824
+ "backup.environmentSaved": "Configuration saved to .env.local; available on the next run even if backup fails."
825
825
  }
@@ -155,8 +155,8 @@
155
155
  "disclaimer.operationCancelled": "Operação cancelada pelo usuário.",
156
156
 
157
157
  "consent.title": "O smoonb irá ler .env.local e preservar o arquivo do projeto.",
158
- "consent.backup": "Uma cópia de .env.local será salva dentro do backup.",
159
- "consent.mapping": "Vamos mapear suas variáveis de ambiente para garantir que todas as chaves necessárias estejam presentes e com os valores corretos do projeto de destino.",
158
+ "consent.backup": "A configuração anterior e a configuração usada serão copiadas para a pasta env do backup.",
159
+ "consent.mapping": "Cada valor confirmado será salvo imediatamente em .env.local, antes do backup. As demais variáveis serão preservadas, e os valores salvos continuarão disponíveis se a execução falhar ou for cancelada.",
160
160
  "consent.proceed": "Você consente em prosseguir?",
161
161
 
162
162
  "help.configuration": "CONFIGURAÇÃO:",
@@ -820,5 +820,5 @@
820
820
  "error.storageCredentialsNotConfigured": "Credenciais do projeto destino não configuradas. É necessário SUPABASE_PROJECT_ID e SUPABASE_ACCESS_TOKEN",
821
821
  "error.supabaseCredentialsNotConfigured": "Credenciais do Supabase não configuradas. É necessário NEXT_PUBLIC_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY",
822
822
  "restore.verified.remapUrls": "Atualizar os endereços Supabase do projeto antigo para o novo nos dados e arquivos? (Inclui URLs em campos JSON; outros textos e IDs são preservados.)",
823
- "backup.environmentPreserved": "Ambiente mapeado para esta execução; arquivo do projeto preservado."
823
+ "backup.environmentSaved": "Configuração salva em .env.local; disponível para a próxima execução, mesmo se o backup falhar."
824
824
  }
@@ -14,7 +14,7 @@ function printSupabasePostgresMajorHintOnce(getT) {
14
14
  ui.hint(` ${getT('env.supabasePostgresMajor.hintBody')}`);
15
15
  }
16
16
 
17
- async function mapEnvVariablesInteractively(env, expectedKeys) {
17
+ async function mapEnvVariablesInteractively(env, expectedKeys, onMapped = async () => {}) {
18
18
  _printedSupabasePostgresMajorHint = false;
19
19
  const finalEnv = { ...env };
20
20
  const dePara = {};
@@ -208,6 +208,7 @@ async function mapEnvVariablesInteractively(env, expectedKeys) {
208
208
  throw new Error(`Duplicidade de mapeamento detectada para ${clientKey}`);
209
209
  }
210
210
  dePara[clientKey] = expected;
211
+ await onMapped({ [clientKey]: finalEnv[clientKey] }, { ...dePara });
211
212
  continue;
212
213
  }
213
214
  }
@@ -261,6 +262,7 @@ async function mapEnvVariablesInteractively(env, expectedKeys) {
261
262
  throw new Error(`Duplicidade de mapeamento detectada para ${clientKey}`);
262
263
  }
263
264
  dePara[clientKey] = expected;
265
+ await onMapped({ [clientKey]: finalEnv[clientKey] }, { ...dePara });
264
266
  }
265
267
 
266
268
  // Após processar todas as variáveis, perguntar sobre idioma padrão se SMOONB_OUTPUT_DIR foi processado
@@ -288,6 +290,7 @@ async function mapEnvVariablesInteractively(env, expectedKeys) {
288
290
  }]);
289
291
 
290
292
  finalEnv.SMOONB_LANG = selectedLang;
293
+ await onMapped({ SMOONB_LANG: selectedLang }, { ...dePara });
291
294
  console.log(chalk.green(`✅ ${getT('env.language.saved', { lang: selectedLang })}`));
292
295
  console.log(chalk.yellow(`💡 ${getT('env.language.note')}`));
293
296
  }
package/src/utils/env.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fsp = require('fs').promises;
2
2
  const path = require('path');
3
+ const { randomUUID } = require('crypto');
3
4
 
4
5
  function parseEnvContent(content) {
5
6
  const lines = content.replace(/\r\n/g, '\n').split('\n');
@@ -9,10 +10,10 @@ function parseEnvContent(content) {
9
10
  if (!trimmed || trimmed.startsWith('#')) continue;
10
11
  const eqIndex = line.indexOf('=');
11
12
  if (eqIndex === -1) continue;
12
- const key = line.slice(0, eqIndex).trim();
13
+ const key = line.slice(0, eqIndex).trim().replace(/^export\s+/, '');
13
14
  let value = line.slice(eqIndex + 1);
14
15
  // Remove optional quotes
15
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
16
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) || (value.startsWith("`") && value.endsWith("`"))) {
16
17
  value = value.slice(1, -1);
17
18
  }
18
19
  entries[key] = value;
@@ -20,10 +21,19 @@ function parseEnvContent(content) {
20
21
  return entries;
21
22
  }
22
23
 
24
+ function encodeEnvValue(value) {
25
+ const raw = String(value ?? '');
26
+ if (!/[\s#"'`]/.test(raw)) return raw;
27
+ const quote = ["'", '`', '"'].find(q => !raw.includes(q) && (q !== '"' || !/\\[nr]/.test(raw)));
28
+ if (!quote) throw new Error('Environment value cannot be safely quoted; file was not changed');
29
+ return quote + raw + quote;
30
+ }
31
+
23
32
  function stringifyEnv(entries, existingContent) {
24
33
  // Best-effort: keep existing comments and order; update or append keys
25
34
  const existingLines = (existingContent || '').replace(/\r\n/g, '\n').split('\n');
26
35
  const seen = new Set();
36
+ const existingEntries = parseEnvContent(existingContent || '');
27
37
  const resultLines = [];
28
38
 
29
39
  for (const line of existingLines) {
@@ -33,12 +43,12 @@ function stringifyEnv(entries, existingContent) {
33
43
  continue;
34
44
  }
35
45
  const eqIndex = line.indexOf('=');
36
- const key = line.slice(0, eqIndex).trim();
46
+ const prefix = line.slice(0, eqIndex);
47
+ const key = prefix.trim().replace(/^export\s+/, '');
37
48
  if (Object.prototype.hasOwnProperty.call(entries, key)) {
38
- const rawValue = entries[key] ?? '';
39
- const needsQuote = /\s|[#]/.test(rawValue);
40
- const safeValue = needsQuote ? `"${rawValue.replace(/"/g, '\\"')}"` : rawValue;
41
- resultLines.push(`${key}=${safeValue}`);
49
+ if (existingEntries[key] === entries[key]) { resultLines.push(line); seen.add(key); continue; }
50
+ const safeValue = encodeEnvValue(entries[key]);
51
+ resultLines.push(`${prefix}=${safeValue}`);
42
52
  seen.add(key);
43
53
  } else {
44
54
  resultLines.push(line);
@@ -47,15 +57,14 @@ function stringifyEnv(entries, existingContent) {
47
57
 
48
58
  for (const [key, value] of Object.entries(entries)) {
49
59
  if (seen.has(key)) continue;
50
- const rawValue = value ?? '';
51
- const needsQuote = /\s|[#]/.test(rawValue);
52
- const safeValue = needsQuote ? `"${rawValue.replace(/"/g, '\\"')}"` : rawValue;
60
+ const safeValue = encodeEnvValue(value);
53
61
  resultLines.push(`${key}=${safeValue}`);
54
62
  }
55
63
 
56
64
  // Ensure trailing newline
57
- let out = resultLines.join('\n');
58
- if (!out.endsWith('\n')) out += '\n';
65
+ const newline = (existingContent || '').includes('\r\n') ? '\r\n' : '\n';
66
+ let out = resultLines.join(newline);
67
+ if (!out.endsWith('\n')) out += newline;
59
68
  return out;
60
69
  }
61
70
 
@@ -73,9 +82,18 @@ async function writeEnvFile(filePath, entries, _options = {}) {
73
82
  const dir = path.dirname(filePath);
74
83
  await fsp.mkdir(dir, { recursive: true });
75
84
  let existing = '';
76
- try { existing = await fsp.readFile(filePath, 'utf8'); } catch {}
85
+ try { existing = await fsp.readFile(filePath, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
86
+ for (const [key, value] of Object.entries(entries)) {
87
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || /[\r\n\0]/.test(String(value ?? ''))) throw new Error('Invalid environment entry; file was not changed');
88
+ }
77
89
  const content = stringifyEnv(entries, existing);
78
- await fsp.writeFile(filePath, content, 'utf8');
90
+ if (content === existing) return;
91
+ const temporary = path.join(dir, `.smoonb-env-${randomUUID()}.tmp`);
92
+ try {
93
+ await fsp.writeFile(temporary, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
94
+ await fsp.rename(temporary, filePath);
95
+ } finally { await fsp.unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
96
+
79
97
  }
80
98
 
81
99
  async function backupEnvFile(srcPath, destPath) {
@@ -86,7 +104,7 @@ async function backupEnvFile(srcPath, destPath) {
86
104
  await fsp.copyFile(srcPath, destPath);
87
105
  } catch (e) {
88
106
  if (e.code === 'ENOENT') {
89
- // Arquivo não existe, não fazer backup de arquivo vazio
107
+ // Arquivo não existe, não fazer backup de arquivo vazio
90
108
  return;
91
109
  }
92
110
  throw e;