smoonb 1.0.12 → 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/CHANGELOG.md +16 -0
- package/README.md +695 -693
- package/README.pt-BR.md +697 -695
- package/npm-shrinkwrap.json +2030 -2030
- package/package.json +71 -71
- package/src/commands/backup/index.js +12 -6
- package/src/commands/backup/outcome.js +9 -1
- package/src/commands/backup/steps/00-license.js +2 -2
- package/src/commands/backup/steps/03-database-separated.js +2 -2
- package/src/commands/backup/steps/11-migrations.js +22 -4
- package/src/i18n/locales/en.json +3 -3
- package/src/i18n/locales/pt-BR.json +3 -3
- package/src/interactive/envMapper.js +4 -1
- package/src/utils/env.js +33 -15
- package/src/utils/pg-dump-diagnostics.js +68 -64
package/package.json
CHANGED
|
@@ -1,71 +1,71 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "smoonb",
|
|
3
|
-
"version": "1.0.
|
|
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
|
|
15
|
-
"postinstall": "echo '\\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
|
-
|
|
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.
|
|
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);
|
|
@@ -403,6 +407,8 @@ module.exports = async (options) => {
|
|
|
403
407
|
} else {
|
|
404
408
|
console.error('BACKUP INCOMPLETO: ' + outcome.failed_components.join(', '));
|
|
405
409
|
console.log('Arquivos preservados em: ' + finalBackupDir);
|
|
410
|
+
for (const line of require('./outcome').componentSummary(manifest.components)) console.log(line);
|
|
411
|
+
console.log('Consulte backup-manifest.json para os detalhes e refaça o backup após resolver as etapas com falha.');
|
|
406
412
|
}
|
|
407
413
|
|
|
408
414
|
// report.json
|
|
@@ -5,4 +5,12 @@ function backupOutcome(components) {
|
|
|
5
5
|
function storageComplete(buckets, expected) {
|
|
6
6
|
return buckets.length === expected && buckets.every(b => !b.listingFailed && b.filesSkipped === 0 && b.filesDownloaded === b.totalFiles && b.objectCount === b.totalFiles);
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
function componentSummary(components) {
|
|
9
|
+
const labels = { database: 'Banco de dados', database_separated: 'SQL separados', database_settings: 'Configurações do banco', auth_settings: 'Auth', realtime: 'Realtime', storage: 'Storage', custom_roles: 'Roles', edge_functions: 'Edge Functions', supabase_temp: 'Arquivos locais Supabase', migrations: 'Migrations' };
|
|
10
|
+
const reasons = { migration_history_check_failed: 'não foi possível consultar o histórico; confira conexão e permissões', migration_export_failed: 'não foi possível exportar o histórico', history_absent: 'nenhum histórico registrado', history_empty: 'histórico vazio' };
|
|
11
|
+
return Object.entries(components).map(([name, result]) => {
|
|
12
|
+
const count = result?.file_count ?? result?.functions_count ?? result?.totalFiles;
|
|
13
|
+
return `${result?.success === true ? 'OK' : 'FALHA'} - ${labels[name] || name}${count === undefined ? '' : ': ' + count}${result?.reason && result.reason !== 'success' ? ' (' + (reasons[result.reason] || result.reason) + ')' : ''}`;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
module.exports = { backupOutcome, storageComplete, componentSummary };
|
|
@@ -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
|
-
|
|
274
|
+
await writeEnvFile(envPath, { [LICENSE_KEY_ENV]: licenseKey });
|
|
275
275
|
process.env[LICENSE_KEY_ENV] = licenseKey;
|
|
276
276
|
}
|
|
277
277
|
|
|
@@ -2,12 +2,12 @@ const chalk = require('chalk');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const fs = require('fs').promises;
|
|
4
4
|
const { t } = require('../../../i18n');
|
|
5
|
-
const { createPgDumpDiagnostics, hasReplicaRolePrologue } = require('../../../utils/pg-dump-diagnostics');
|
|
5
|
+
const { createPgDumpDiagnostics, hasReplicaRolePrologue, withoutCliUpgradeNotice } = require('../../../utils/pg-dump-diagnostics');
|
|
6
6
|
|
|
7
7
|
const { isolatedSupabase } = require('../../../utils/supabaseCli');
|
|
8
8
|
async function runWithElapsedTicker(_command, args, env) {
|
|
9
9
|
const result = await isolatedSupabase(args, env, { capture: true });
|
|
10
|
-
let stderr =
|
|
10
|
+
let stderr = withoutCliUpgradeNotice(result.stderr || '');
|
|
11
11
|
const secrets = Object.values(env || {}).filter(Boolean);
|
|
12
12
|
const databaseUrl = args[args.indexOf('--db-url') + 1];
|
|
13
13
|
if (databaseUrl) {
|
|
@@ -2,16 +2,34 @@ const path = require('path');
|
|
|
2
2
|
const fs = require('fs').promises;
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const { runSupabase } = require('../../../utils/supabaseCli');
|
|
5
|
+
const { runSql } = require('../../restore/pg');
|
|
5
6
|
const { copyDirectoryRecursive } = require('../../restore/utils');
|
|
6
|
-
module.exports = async ({ databaseUrl, accessToken, backupDir }) => {
|
|
7
|
+
module.exports = async ({ databaseUrl, accessToken, backupDir, postgresMajor = 17 }, { query = runSql, run = runSupabase } = {}) => {
|
|
7
8
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'smoonb-migrations-'));
|
|
9
|
+
let phase = 'migration_history_check_failed';
|
|
8
10
|
try {
|
|
11
|
+
const history = await query(databaseUrl, "BEGIN READ ONLY; SET LOCAL statement_timeout = '20s'; SELECT to_regclass('supabase_migrations.schema_migrations') IS NOT NULL; COMMIT;", { postgresMajor });
|
|
12
|
+
if (!['t', 'f'].includes(history.trim())) throw new Error('Unexpected history response');
|
|
13
|
+
let count = 0;
|
|
14
|
+
if (history.trim() === 't') {
|
|
15
|
+
const value = await query(databaseUrl, "BEGIN READ ONLY; SET LOCAL statement_timeout = '20s'; SELECT count(*) FROM supabase_migrations.schema_migrations; COMMIT;", { postgresMajor });
|
|
16
|
+
if (!/^\d+$/.test(value.trim())) throw new Error('Unexpected migration count');
|
|
17
|
+
count = Number(value.trim());
|
|
18
|
+
}
|
|
19
|
+
if (count === 0) {
|
|
20
|
+
await fs.mkdir(path.join(backupDir, 'migrations'), { recursive: true });
|
|
21
|
+
console.log('Migrations: nenhum histórico registrado no banco; nada a exportar.');
|
|
22
|
+
return { success: true, file_count: 0, reason: history.trim() === 'f' ? 'history_absent' : 'history_empty' };
|
|
23
|
+
}
|
|
24
|
+
phase = 'migration_export_failed';
|
|
9
25
|
await fs.mkdir(path.join(root, 'supabase', 'migrations'), { recursive: true });
|
|
10
26
|
await fs.writeFile(path.join(root, 'supabase', 'config.toml'), 'project_id = "smoonb-backup"\n');
|
|
11
|
-
|
|
27
|
+
run('supabase', ['migration', 'fetch', '--db-url', databaseUrl, '--workdir', root, '--yes'], { cwd: root, env: { ...process.env, SUPABASE_ACCESS_TOKEN: accessToken }, stdio: 'pipe' });
|
|
12
28
|
const source = path.join(root, 'supabase', 'migrations');
|
|
13
29
|
await copyDirectoryRecursive(source, path.join(backupDir, 'migrations'));
|
|
14
|
-
|
|
15
|
-
|
|
30
|
+
const fileCount = (await fs.readdir(source)).filter(name => name.endsWith('.sql')).length;
|
|
31
|
+
if (fileCount !== count) throw new Error('Migration export count mismatch');
|
|
32
|
+
return { success: true, file_count: fileCount };
|
|
33
|
+
} catch { return { success: false, reason: phase, action: 'Check database connectivity and access to supabase_migrations.schema_migrations; retry the backup.' }; }
|
|
16
34
|
finally { await fs.rm(root, { recursive: true, force: true }); }
|
|
17
35
|
};
|
package/src/i18n/locales/en.json
CHANGED
|
@@ -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": "
|
|
159
|
-
"consent.mapping": "
|
|
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.
|
|
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": "
|
|
159
|
-
"consent.mapping": "
|
|
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.
|
|
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
|
|
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
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
107
|
+
// Arquivo não existe, não fazer backup de arquivo vazio
|
|
90
108
|
return;
|
|
91
109
|
}
|
|
92
110
|
throw e;
|
|
@@ -1,64 +1,68 @@
|
|
|
1
|
-
const { StringDecoder } = require('string_decoder');
|
|
2
|
-
const fs = require('fs').promises;
|
|
3
|
-
|
|
4
|
-
// Only the exact, known pg_dump circular-FK block is consolidated. All other
|
|
5
|
-
// stderr (including errors and unfamiliar/localized warnings) remains visible.
|
|
6
|
-
function createPgDumpDiagnostics(writeStderr) {
|
|
7
|
-
const decoder = new StringDecoder('utf8');
|
|
8
|
-
let pending = '';
|
|
9
|
-
let inCircularWarning = false;
|
|
10
|
-
const warnings = [];
|
|
11
|
-
const tables = new Set();
|
|
12
|
-
const hints = new Set([
|
|
13
|
-
'pg_dump: hint: You might not be able to restore the dump without using --disable-triggers or temporarily dropping the constraints.',
|
|
14
|
-
'pg_dump: hint: Consider using a full dump instead of a --data-only dump to avoid this problem.'
|
|
15
|
-
]);
|
|
16
|
-
function line(raw) {
|
|
17
|
-
const text = raw.replace(/\r?\n$/, '');
|
|
18
|
-
if (/^pg_dump: warning: there are circular foreign-key constraints (?:among these tables|on this table):$/.test(text)) {
|
|
19
|
-
inCircularWarning = true;
|
|
20
|
-
warnings.push(text);
|
|
21
|
-
} else if (inCircularWarning && text.startsWith('pg_dump: detail: ')) {
|
|
22
|
-
tables.add(text.slice('pg_dump: detail: '.length));
|
|
23
|
-
warnings.push(text);
|
|
24
|
-
} else if (inCircularWarning && hints.has(text)) {
|
|
25
|
-
warnings.push(text);
|
|
26
|
-
} else {
|
|
27
|
-
inCircularWarning = false;
|
|
28
|
-
writeStderr(raw);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
function consume(text) {
|
|
32
|
-
pending += text;
|
|
33
|
-
let end;
|
|
34
|
-
while ((end = pending.indexOf('\n')) !== -1) {
|
|
35
|
-
line(pending.slice(0, end + 1));
|
|
36
|
-
pending = pending.slice(end + 1);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return {
|
|
40
|
-
write(chunk) { consume(decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); },
|
|
41
|
-
end() {
|
|
42
|
-
consume(decoder.end());
|
|
43
|
-
if (pending) line(pending);
|
|
44
|
-
pending = '';
|
|
45
|
-
return { circularForeignKeys: warnings.length > 0, tables: [...tables], messages: warnings };
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Supabase prepends this command before pg_dump output. Check the first line,
|
|
51
|
-
// not arbitrary table contents: a data row containing SQL is not protection.
|
|
52
|
-
async function hasReplicaRolePrologue(file) {
|
|
53
|
-
const handle = await fs.open(file, 'r');
|
|
54
|
-
try {
|
|
55
|
-
const buffer = Buffer.alloc(256);
|
|
56
|
-
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
57
|
-
const firstLine = buffer.toString('utf8', 0, bytesRead).split('\n')[0].replace(/^\uFEFF/, '').trim();
|
|
58
|
-
return /^SET\s+session_replication_role\s*=\s*(?:replica|'replica');$/i.test(firstLine);
|
|
59
|
-
} finally {
|
|
60
|
-
await handle.close();
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
1
|
+
const { StringDecoder } = require('string_decoder');
|
|
2
|
+
const fs = require('fs').promises;
|
|
3
|
+
|
|
4
|
+
// Only the exact, known pg_dump circular-FK block is consolidated. All other
|
|
5
|
+
// stderr (including errors and unfamiliar/localized warnings) remains visible.
|
|
6
|
+
function createPgDumpDiagnostics(writeStderr) {
|
|
7
|
+
const decoder = new StringDecoder('utf8');
|
|
8
|
+
let pending = '';
|
|
9
|
+
let inCircularWarning = false;
|
|
10
|
+
const warnings = [];
|
|
11
|
+
const tables = new Set();
|
|
12
|
+
const hints = new Set([
|
|
13
|
+
'pg_dump: hint: You might not be able to restore the dump without using --disable-triggers or temporarily dropping the constraints.',
|
|
14
|
+
'pg_dump: hint: Consider using a full dump instead of a --data-only dump to avoid this problem.'
|
|
15
|
+
]);
|
|
16
|
+
function line(raw) {
|
|
17
|
+
const text = raw.replace(/\r?\n$/, '');
|
|
18
|
+
if (/^pg_dump: warning: there are circular foreign-key constraints (?:among these tables|on this table):$/.test(text)) {
|
|
19
|
+
inCircularWarning = true;
|
|
20
|
+
warnings.push(text);
|
|
21
|
+
} else if (inCircularWarning && text.startsWith('pg_dump: detail: ')) {
|
|
22
|
+
tables.add(text.slice('pg_dump: detail: '.length));
|
|
23
|
+
warnings.push(text);
|
|
24
|
+
} else if (inCircularWarning && hints.has(text)) {
|
|
25
|
+
warnings.push(text);
|
|
26
|
+
} else {
|
|
27
|
+
inCircularWarning = false;
|
|
28
|
+
writeStderr(raw);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function consume(text) {
|
|
32
|
+
pending += text;
|
|
33
|
+
let end;
|
|
34
|
+
while ((end = pending.indexOf('\n')) !== -1) {
|
|
35
|
+
line(pending.slice(0, end + 1));
|
|
36
|
+
pending = pending.slice(end + 1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
write(chunk) { consume(decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); },
|
|
41
|
+
end() {
|
|
42
|
+
consume(decoder.end());
|
|
43
|
+
if (pending) line(pending);
|
|
44
|
+
pending = '';
|
|
45
|
+
return { circularForeignKeys: warnings.length > 0, tables: [...tables], messages: warnings };
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Supabase prepends this command before pg_dump output. Check the first line,
|
|
51
|
+
// not arbitrary table contents: a data row containing SQL is not protection.
|
|
52
|
+
async function hasReplicaRolePrologue(file) {
|
|
53
|
+
const handle = await fs.open(file, 'r');
|
|
54
|
+
try {
|
|
55
|
+
const buffer = Buffer.alloc(256);
|
|
56
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
57
|
+
const firstLine = buffer.toString('utf8', 0, bytesRead).split('\n')[0].replace(/^\uFEFF/, '').trim();
|
|
58
|
+
return /^SET\s+session_replication_role\s*=\s*(?:replica|'replica');$/i.test(firstLine);
|
|
59
|
+
} finally {
|
|
60
|
+
await handle.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The bundled CLI is deliberately pinned. Suppress only its generic upgrade advert.
|
|
65
|
+
function withoutCliUpgradeNotice(text) {
|
|
66
|
+
return String(text).split(/(?<=\n)/).filter(line => !/^A new version of Supabase CLI is available: v[0-9.]+ \(currently installed v[0-9.]+\)\r?\n?$/.test(line) && !/^We recommend updating regularly for new features and bug fixes: https:\/\/supabase\.com\/docs\/guides\/cli\/getting-started#updating-the-supabase-cli\r?\n?$/.test(line)).join('');
|
|
67
|
+
}
|
|
68
|
+
module.exports = { createPgDumpDiagnostics, hasReplicaRolePrologue, withoutCliUpgradeNotice };
|