smoonb 0.0.69 → 0.0.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,7 +23,7 @@ O smoonb faz backup completo de todos os componentes do seu projeto Supabase:
23
23
  - ✅ **Custom Roles** (roles personalizados do PostgreSQL)
24
24
  - ✅ **Edge Functions** (download automático do servidor)
25
25
  - ✅ **Auth Settings** (configurações de autenticação via Management API)
26
- - ✅ **Storage Buckets** (metadados e configurações via Management API)
26
+ - ✅ **Storage Buckets** (backup completo: metadados, configurações e todos os arquivos via Management API + Supabase Client, cria ZIP no padrão do Dashboard)
27
27
  - ✅ **Realtime Settings** (7 parâmetros capturados interativamente)
28
28
  - ✅ **Supabase .temp** (arquivos temporários do Supabase CLI)
29
29
  - ✅ **Migrations** (todas as migrations do projeto via `supabase migration fetch`)
@@ -169,7 +169,7 @@ npx smoonb backup
169
169
  4. **Backup do .env.local** - Cria backup automático antes de alterações
170
170
  5. **Seleção de Componentes** - Pergunta quais componentes incluir:
171
171
  - ⚡ Edge Functions (explicação sobre reset de link e download)
172
- - 📦 Storage (explicação sobre metadados)
172
+ - 📦 Storage (explicação sobre backup completo: download de arquivos + ZIP no padrão do Dashboard)
173
173
  - 🔐 Auth Settings (explicação sobre configurações)
174
174
  - 🔄 Realtime Settings (explicação sobre captura interativa de 7 parâmetros)
175
175
  - 🗑️ Opções de limpeza (functions, .temp, migrations após backup)
@@ -181,7 +181,7 @@ npx smoonb backup
181
181
  - 🔧 3/10 - Backup Database Extensions and Settings
182
182
  - 🔐 4/10 - Backup Auth Settings (se selecionado)
183
183
  - 🔄 5/10 - Backup Realtime Settings (se selecionado) - 7 parâmetros capturados interativamente
184
- - 📦 6/10 - Backup Storage (se selecionado)
184
+ - 📦 6/10 - Backup Storage (se selecionado) - Download completo de arquivos + ZIP no padrão do Dashboard
185
185
  - 👥 7/10 - Backup Custom Roles
186
186
  - ⚡ 8/10 - Backup Edge Functions (se selecionado)
187
187
  - 📁 9/10 - Backup Supabase .temp (se selecionado)
@@ -199,6 +199,11 @@ backups/backup-2025-10-31-09-37-54/
199
199
  ├── auth-settings.json # Configurações de Auth
200
200
  ├── realtime-settings.json # Configurações de Realtime
201
201
  ├── storage/ # Metadados de Storage
202
+ │ └── [bucket-name].json # Metadados de cada bucket
203
+ ├── [project-id].storage.zip # Backup completo de Storage (padrão Dashboard)
204
+ ├── storage_temp/ # Estrutura temporária (opcional, pode ser removida)
205
+ │ └── [project-id]/ # Estrutura de arquivos baixados
206
+ │ └── [bucket-name]/ # Arquivos de cada bucket
202
207
  ├── edge-functions/ # Edge Functions baixadas
203
208
  │ └── [nome-da-function]/
204
209
  ├── supabase-temp/ # Arquivos .temp do Supabase CLI
@@ -247,7 +252,7 @@ npx smoonb restore --file "backup.backup.gz" --storage "meu-projeto.storage.zip"
247
252
  - 📊 Database - Restaura via `psql` (suporta `.backup.gz` e `.backup`)
248
253
  - ⚡ Edge Functions - Copia e faz deploy no projeto destino
249
254
  - 🔐 Auth Settings - Exibe configurações para aplicação manual
250
- - 📦 Storage - Exibe informações para migração manual
255
+ - 📦 Storage - Restaura buckets e arquivos do ZIP (se disponível) ou exibe informações para migração manual
251
256
  - 🔧 Database Settings - Restaura extensões e configurações via SQL
252
257
  - 🔄 Realtime Settings - Exibe configurações para aplicação manual
253
258
 
@@ -432,7 +437,17 @@ restore/
432
437
  - **Reset de Link**: Garante link limpo com o projeto
433
438
  - **Backup Completo**: Todas as migrations do servidor
434
439
 
435
- #### Auth, Storage, Realtime
440
+ #### Storage
441
+ - **Backup Completo**: Download de todos os arquivos de todos os buckets
442
+ - **Estrutura Temporária**: Cria `storage_temp/project-id/bucket-name/arquivos...` dentro do backupDir
443
+ - **ZIP no Padrão Dashboard**: Cria `{project-id}.storage.zip` com estrutura `project-id/bucket-name/arquivos...`
444
+ - **Compatível com Restore**: O ZIP criado é compatível com o processo de restore (mesmo formato do Dashboard)
445
+ - **Pergunta Interativa**: Após criar o ZIP, pergunta se deseja limpar a estrutura temporária
446
+ - **Fallback**: Se não houver credenciais do Supabase, faz backup apenas de metadados
447
+ - **Management API**: Usa Personal Access Token para listar buckets e objetos
448
+ - **Supabase Client**: Usa Service Role Key para download de arquivos
449
+
450
+ #### Auth, Realtime
436
451
  - **Management API**: Usa Personal Access Token
437
452
  - **JSON Export**: Configurações exportadas como JSON
438
453
  - **Realtime Settings**: Captura interativa de 7 parâmetros:
@@ -460,7 +475,8 @@ restore/
460
475
 
461
476
  #### Outros Componentes
462
477
  - **Database Settings**: Restaura via SQL
463
- - **Auth/Storage/Realtime**: Exibe informações para configuração manual no Dashboard
478
+ - **Storage**: Restaura buckets e arquivos do ZIP (se disponível) ou exibe informações para configuração manual
479
+ - **Auth/Realtime**: Exibe informações para configuração manual no Dashboard
464
480
 
465
481
  ### Multiplataforma
466
482
 
@@ -495,10 +511,10 @@ npx smoonb restore
495
511
  # 6. Verificar integridade
496
512
  npx smoonb check
497
513
 
498
- # 7. Aplicar configurações manuais
514
+ # 7. Aplicar configurações manuais (se necessário)
499
515
  # - Auth Settings: Dashboard → Authentication → Settings
500
- # - Storage: Dashboard → Storage → Buckets
501
516
  # - Realtime: Dashboard → Database → Replication
517
+ # Nota: Storage é restaurado automaticamente do ZIP se disponível
502
518
  ```
503
519
 
504
520
  ## 🎨 Experiência do Usuário
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoonb",
3
- "version": "0.0.69",
3
+ "version": "0.0.71",
4
4
  "description": "Complete Supabase backup and migration tool - EXPERIMENTAL VERSION - USE AT YOUR OWN RISK",
5
5
  "preferGlobal": false,
6
6
  "preventGlobalInstall": true,
@@ -119,6 +119,8 @@ module.exports = async (options) => {
119
119
  const projectId = getValue('SUPABASE_PROJECT_ID');
120
120
  const accessToken = getValue('SUPABASE_ACCESS_TOKEN');
121
121
  const databaseUrl = getValue('SUPABASE_DB_URL');
122
+ const supabaseUrl = getValue('NEXT_PUBLIC_SUPABASE_URL');
123
+ const supabaseServiceKey = getValue('SUPABASE_SERVICE_ROLE_KEY');
122
124
 
123
125
  if (!databaseUrl) {
124
126
  console.log(chalk.red('❌ DATABASE_URL NÃO CONFIGURADA'));
@@ -187,6 +189,8 @@ module.exports = async (options) => {
187
189
  projectId,
188
190
  accessToken,
189
191
  databaseUrl,
192
+ supabaseUrl,
193
+ supabaseServiceKey,
190
194
  backupDir: finalBackupDir,
191
195
  outputDir: resolvedOutputDir,
192
196
  options: { ...options, flags },
@@ -326,7 +330,11 @@ module.exports = async (options) => {
326
330
 
327
331
  if (flags?.includeStorage && manifest.components.storage) {
328
332
  const storageResult = manifest.components.storage;
329
- console.log(chalk.green(`📦 Storage: ${storageResult.buckets?.length || 0} buckets verificados via API`));
333
+ if (storageResult.zipFile) {
334
+ console.log(chalk.green(`📦 Storage: ${storageResult.buckets?.length || 0} buckets, ${storageResult.totalFiles || 0} arquivo(s) baixado(s), ZIP: ${storageResult.zipFile} (${storageResult.zipSizeMB || 0} MB)`));
335
+ } else {
336
+ console.log(chalk.green(`📦 Storage: ${storageResult.buckets?.length || 0} buckets verificados via API (apenas metadados)`));
337
+ }
330
338
  }
331
339
 
332
340
  console.log(chalk.green(`👥 Custom Roles: ${rolesResult.roles?.length || 0} roles exportados via SQL`));
@@ -1,11 +1,16 @@
1
1
  const chalk = require('chalk');
2
2
  const path = require('path');
3
+ const fs = require('fs').promises;
4
+ const AdmZip = require('adm-zip');
5
+ const { createClient } = require('@supabase/supabase-js');
3
6
  const { ensureDir, writeJson } = require('../../../utils/fsx');
7
+ const { confirm } = require('../../../utils/prompt');
4
8
 
5
9
  /**
6
10
  * Etapa 6: Backup Storage via Supabase API
11
+ * Agora faz backup completo: metadados + download de todos os arquivos + ZIP no padrão do Dashboard
7
12
  */
8
- module.exports = async ({ projectId, accessToken, backupDir }) => {
13
+ module.exports = async ({ projectId, accessToken, backupDir, supabaseUrl, supabaseServiceKey }) => {
9
14
  try {
10
15
  const storageDir = path.join(backupDir, 'storage');
11
16
  await ensureDir(storageDir);
@@ -37,7 +42,25 @@ module.exports = async ({ projectId, accessToken, backupDir }) => {
37
42
 
38
43
  console.log(chalk.white(` - Encontrados ${buckets.length} buckets`));
39
44
 
45
+ // Validar credenciais do Supabase para download de arquivos
46
+ if (!supabaseUrl || !supabaseServiceKey) {
47
+ console.log(chalk.yellow(' ⚠️ Credenciais do Supabase não disponíveis. Fazendo backup apenas de metadados...'));
48
+ return await backupMetadataOnly(buckets, storageDir, projectId, accessToken);
49
+ }
50
+
51
+ // Criar cliente Supabase para download de arquivos
52
+ const supabase = createClient(supabaseUrl, supabaseServiceKey);
53
+
54
+ // Criar estrutura temporária para armazenar arquivos baixados
55
+ const tempStorageDir = path.join(backupDir, 'storage_temp');
56
+ await ensureDir(tempStorageDir);
57
+
58
+ // Criar estrutura: storage_temp/project-id/bucket-name/arquivos...
59
+ const projectStorageDir = path.join(tempStorageDir, projectId);
60
+ await ensureDir(projectStorageDir);
61
+
40
62
  const processedBuckets = [];
63
+ let totalFilesDownloaded = 0;
41
64
 
42
65
  for (const bucket of buckets || []) {
43
66
  try {
@@ -69,22 +92,222 @@ module.exports = async ({ projectId, accessToken, backupDir }) => {
69
92
  const bucketPath = path.join(storageDir, `${bucket.name}.json`);
70
93
  await writeJson(bucketPath, bucketInfo);
71
94
 
95
+ // Baixar todos os arquivos do bucket
96
+ const bucketDir = path.join(projectStorageDir, bucket.name);
97
+ await ensureDir(bucketDir);
98
+
99
+ // Listar todos os arquivos recursivamente usando Supabase client
100
+ console.log(chalk.white(` - Listando arquivos do bucket ${bucket.name}...`));
101
+ const allFiles = await listAllFilesRecursively(supabase, bucket.name, '');
102
+
103
+ let filesDownloaded = 0;
104
+ if (allFiles.length > 0) {
105
+ console.log(chalk.white(` - Baixando ${allFiles.length} arquivo(s) do bucket ${bucket.name}...`));
106
+
107
+ for (const filePath of allFiles) {
108
+ try {
109
+ // Baixar arquivo do Storage
110
+ const { data: fileData, error: downloadError } = await supabase.storage
111
+ .from(bucket.name)
112
+ .download(filePath);
113
+
114
+ if (downloadError) {
115
+ console.log(chalk.yellow(` ⚠️ Erro ao baixar ${filePath}: ${downloadError.message}`));
116
+ continue;
117
+ }
118
+
119
+ // Criar estrutura de pastas local se necessário
120
+ const localFilePath = path.join(bucketDir, filePath);
121
+ const localFileDir = path.dirname(localFilePath);
122
+ await ensureDir(localFileDir);
123
+
124
+ // Salvar arquivo localmente
125
+ const arrayBuffer = await fileData.arrayBuffer();
126
+ const buffer = Buffer.from(arrayBuffer);
127
+ await fs.writeFile(localFilePath, buffer);
128
+ filesDownloaded++;
129
+
130
+ // Mostrar progresso a cada 10 arquivos ou se for o último
131
+ if (filesDownloaded % 10 === 0 || filesDownloaded === allFiles.length) {
132
+ console.log(chalk.white(` - Baixados ${filesDownloaded}/${allFiles.length} arquivo(s)...`));
133
+ }
134
+ } catch (fileError) {
135
+ console.log(chalk.yellow(` ⚠️ Erro ao processar arquivo ${filePath}: ${fileError.message}`));
136
+ }
137
+ }
138
+ }
139
+
140
+ totalFilesDownloaded += filesDownloaded;
72
141
  processedBuckets.push({
73
142
  name: bucket.name,
74
- objectCount: objects?.length || 0
143
+ objectCount: objects?.length || 0,
144
+ filesDownloaded: filesDownloaded,
145
+ totalFiles: allFiles.length
75
146
  });
76
147
 
77
- console.log(chalk.green(` ✅ Bucket ${bucket.name}: ${objects?.length || 0} objetos`));
148
+ console.log(chalk.green(` ✅ Bucket ${bucket.name}: ${filesDownloaded}/${allFiles.length} arquivo(s) baixado(s)`));
78
149
  } catch (error) {
79
150
  console.log(chalk.yellow(` ⚠️ Erro ao processar bucket ${bucket.name}: ${error.message}`));
80
151
  }
81
152
  }
82
153
 
83
- console.log(chalk.green(`✅ Storage backupado: ${processedBuckets.length} buckets`));
84
- return { success: true, buckets: processedBuckets };
154
+ // Criar ZIP no padrão do Dashboard: {project-id}.storage.zip
155
+ console.log(chalk.white('\n - Criando arquivo ZIP no padrão do Dashboard...'));
156
+ const zipFileName = `${projectId}.storage.zip`;
157
+ const zipFilePath = path.join(backupDir, zipFileName);
158
+
159
+ const zip = new AdmZip();
160
+
161
+ // Adicionar toda a estrutura de pastas ao ZIP
162
+ // Estrutura: project-id/bucket-name/arquivos...
163
+ await addDirectoryToZip(zip, projectStorageDir, projectId);
164
+
165
+ // Salvar ZIP
166
+ zip.writeZip(zipFilePath);
167
+ const zipStats = await fs.stat(zipFilePath);
168
+ const zipSizeMB = (zipStats.size / (1024 * 1024)).toFixed(2);
169
+
170
+ console.log(chalk.green(` ✅ Arquivo ZIP criado: ${zipFileName} (${zipSizeMB} MB)`));
171
+
172
+ // Perguntar ao usuário se deseja limpar a estrutura temporária
173
+ const tempDirName = path.basename(tempStorageDir);
174
+ const shouldCleanup = await confirm(` Deseja limpar ${tempDirName} após o backup`, false);
175
+
176
+ if (shouldCleanup) {
177
+ console.log(chalk.white(` - Limpando estrutura temporária...`));
178
+ try {
179
+ await fs.rm(tempStorageDir, { recursive: true, force: true });
180
+ console.log(chalk.green(` ✅ Estrutura temporária removida`));
181
+ } catch (cleanupError) {
182
+ console.log(chalk.yellow(` ⚠️ Erro ao limpar estrutura temporária: ${cleanupError.message}`));
183
+ }
184
+ } else {
185
+ console.log(chalk.white(` ℹ️ Estrutura temporária mantida em: ${path.relative(process.cwd(), tempStorageDir)}`));
186
+ }
187
+
188
+ console.log(chalk.green(`✅ Storage backupado: ${processedBuckets.length} buckets, ${totalFilesDownloaded} arquivo(s) baixado(s)`));
189
+ return {
190
+ success: true,
191
+ buckets: processedBuckets,
192
+ zipFile: zipFileName,
193
+ zipSizeMB: zipSizeMB,
194
+ totalFiles: totalFilesDownloaded,
195
+ tempDirCleaned: shouldCleanup
196
+ };
85
197
  } catch (error) {
86
198
  console.log(chalk.yellow(`⚠️ Erro no backup do Storage: ${error.message}`));
87
199
  return { success: false, buckets: [] };
88
200
  }
89
201
  };
90
202
 
203
+ /**
204
+ * Backup apenas de metadados (fallback quando não há credenciais do Supabase)
205
+ */
206
+ async function backupMetadataOnly(buckets, storageDir, projectId, accessToken) {
207
+ const processedBuckets = [];
208
+
209
+ for (const bucket of buckets || []) {
210
+ try {
211
+ console.log(chalk.white(` - Processando bucket: ${bucket.name}`));
212
+
213
+ const objectsResponse = await fetch(`https://api.supabase.com/v1/projects/${projectId}/storage/buckets/${bucket.name}/objects`, {
214
+ headers: {
215
+ 'Authorization': `Bearer ${accessToken}`,
216
+ 'Content-Type': 'application/json'
217
+ }
218
+ });
219
+
220
+ let objects = [];
221
+ if (objectsResponse.ok) {
222
+ objects = await objectsResponse.json();
223
+ }
224
+
225
+ const bucketInfo = {
226
+ id: bucket.id,
227
+ name: bucket.name,
228
+ public: bucket.public,
229
+ file_size_limit: bucket.file_size_limit,
230
+ allowed_mime_types: bucket.allowed_mime_types,
231
+ objects: objects || []
232
+ };
233
+
234
+ const bucketPath = path.join(storageDir, `${bucket.name}.json`);
235
+ await writeJson(bucketPath, bucketInfo);
236
+
237
+ processedBuckets.push({
238
+ name: bucket.name,
239
+ objectCount: objects?.length || 0
240
+ });
241
+
242
+ console.log(chalk.green(` ✅ Bucket ${bucket.name}: ${objects?.length || 0} objetos`));
243
+ } catch (error) {
244
+ console.log(chalk.yellow(` ⚠️ Erro ao processar bucket ${bucket.name}: ${error.message}`));
245
+ }
246
+ }
247
+
248
+ console.log(chalk.green(`✅ Storage backupado (apenas metadados): ${processedBuckets.length} buckets`));
249
+ return { success: true, buckets: processedBuckets };
250
+ }
251
+
252
+ /**
253
+ * Lista todos os arquivos recursivamente de um bucket do Storage
254
+ */
255
+ async function listAllFilesRecursively(supabase, bucketName, folderPath = '') {
256
+ const allFiles = [];
257
+
258
+ try {
259
+ // Listar arquivos e pastas no caminho atual
260
+ const { data: items, error } = await supabase.storage
261
+ .from(bucketName)
262
+ .list(folderPath, {
263
+ limit: 1000,
264
+ sortBy: { column: 'name', order: 'asc' }
265
+ });
266
+
267
+ if (error) {
268
+ console.log(chalk.yellow(` ⚠️ Erro ao listar ${folderPath || 'raiz'}: ${error.message}`));
269
+ return allFiles;
270
+ }
271
+
272
+ if (!items || items.length === 0) {
273
+ return allFiles;
274
+ }
275
+
276
+ for (const item of items) {
277
+ const itemPath = folderPath ? `${folderPath}/${item.name}` : item.name;
278
+
279
+ if (item.id === null) {
280
+ // É uma pasta, listar recursivamente
281
+ const subFiles = await listAllFilesRecursively(supabase, bucketName, itemPath);
282
+ allFiles.push(...subFiles);
283
+ } else {
284
+ // É um arquivo
285
+ allFiles.push(itemPath);
286
+ }
287
+ }
288
+ } catch (error) {
289
+ console.log(chalk.yellow(` ⚠️ Erro ao processar ${folderPath || 'raiz'}: ${error.message}`));
290
+ }
291
+
292
+ return allFiles;
293
+ }
294
+
295
+ /**
296
+ * Adiciona um diretório recursivamente ao ZIP mantendo a estrutura de pastas
297
+ */
298
+ async function addDirectoryToZip(zip, dirPath, basePath = '') {
299
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
300
+
301
+ for (const entry of entries) {
302
+ const fullPath = path.join(dirPath, entry.name);
303
+ const zipPath = basePath ? `${basePath}/${entry.name}` : entry.name;
304
+
305
+ if (entry.isDirectory()) {
306
+ await addDirectoryToZip(zip, fullPath, zipPath);
307
+ } else {
308
+ const fileContent = await fs.readFile(fullPath);
309
+ zip.addFile(zipPath, fileContent);
310
+ }
311
+ }
312
+ }
313
+
@@ -100,6 +100,7 @@ module.exports = async ({ backupPath, targetProject }) => {
100
100
 
101
101
  // 3. Selecionar o primeiro arquivo .storage.zip encontrado
102
102
  const storageZipFile = path.join(backupPath, storageZipFiles[0]);
103
+ const storageZipBaseName = path.basename(storageZipFiles[0], '.storage.zip');
103
104
  console.log(chalk.white(` - Arquivo de storage encontrado: ${storageZipFiles[0]}`));
104
105
 
105
106
  // 4. Validar credenciais do projeto destino
@@ -155,17 +156,30 @@ module.exports = async ({ backupPath, targetProject }) => {
155
156
  const firstItem = extractedContents[0];
156
157
  const firstItemPath = path.join(extractDir, firstItem);
157
158
  const firstItemStats = await fs.stat(firstItemPath);
158
-
159
+
159
160
  if (firstItemStats.isDirectory()) {
160
161
  // Verificar se o nome da pasta raiz corresponde ao Project ID antigo OU novo
161
- const isProjectId =
162
- (sourceProjectId && firstItem === sourceProjectId) ||
163
- (firstItem === targetProject.targetProjectId);
164
-
162
+ const matchesSourceProjectId = sourceProjectId && firstItem === sourceProjectId;
163
+ const matchesTargetProjectId = firstItem === targetProject.targetProjectId;
164
+ const matchesZipFileName = firstItem === storageZipBaseName;
165
+ const matchesProjectIdPattern = isLikelyProjectId(firstItem);
166
+
167
+ const isProjectId =
168
+ matchesSourceProjectId ||
169
+ matchesTargetProjectId ||
170
+ matchesZipFileName ||
171
+ matchesProjectIdPattern;
172
+
165
173
  if (isProjectId) {
166
174
  // A pasta raiz é um wrapper do Project ID - SEMPRE buscar buckets nas subpastas
167
175
  rootDir = firstItem;
168
- console.log(chalk.white(` - Detectada pasta raiz com Project ID: ${firstItem}`));
176
+ const reasons = [];
177
+ if (matchesSourceProjectId) reasons.push('manifest');
178
+ if (matchesTargetProjectId) reasons.push('projeto destino');
179
+ if (matchesZipFileName) reasons.push('nome do arquivo ZIP');
180
+ if (matchesProjectIdPattern) reasons.push('formato de Project ID');
181
+ const reasonText = reasons.length ? ` (${reasons.join(', ')})` : '';
182
+ console.log(chalk.white(` - Detectada pasta raiz com Project ID: ${firstItem}${reasonText}`));
169
183
  console.log(chalk.white(` - Buscando buckets nas subpastas...`));
170
184
  }
171
185
  }
@@ -551,3 +565,12 @@ function getContentType(fileName) {
551
565
 
552
566
  return contentTypes[ext] || 'application/octet-stream';
553
567
  }
568
+
569
+ function isLikelyProjectId(name) {
570
+ if (!name || typeof name !== 'string') {
571
+ return false;
572
+ }
573
+
574
+ // Project IDs do Supabase são normalmente strings de 20 caracteres alfanuméricos minúsculos
575
+ return /^[a-z0-9]{20}$/.test(name);
576
+ }