diff --git a/.changeset/bright-teams-decide.md b/.changeset/bright-teams-decide.md new file mode 100644 index 0000000..cca9c93 --- /dev/null +++ b/.changeset/bright-teams-decide.md @@ -0,0 +1,8 @@ +--- +"@calycode/types": minor +"@calycode/utils": minor +"@calycode/core": minor +"@calycode/cli": minor +--- + +feat: fixing and wrapping up backup exporting command diff --git a/packages/cli/src/commands/backups.ts b/packages/cli/src/commands/backups.ts index d1d6cf2..66d3819 100644 --- a/packages/cli/src/commands/backups.ts +++ b/packages/cli/src/commands/backups.ts @@ -96,6 +96,29 @@ async function restorationWizard({ instance, workspace, sourceBackup, forceConfi } } +async function exportWizard({ instance, workspace, branch, core, doLog, output }) { + attachCliEventHandlers('export-backup', core, arguments); + + const { instanceConfig, workspaceConfig, branchConfig, context } = await resolveConfigs({ + cliContext: { instance, workspace, branch }, + core, + }); + + // Resolve output dir + const outputDir = output + ? output + : replacePlaceholders(instanceConfig.backups.output, { + '@': await findProjectRoot(), + instance: instanceConfig.name, + workspace: workspaceConfig.name, + branch: branchConfig.label, + }); + + const outputObject = await core.exportBackup({ ...context, outputDir }); + + printOutputDir(doLog, outputObject.outputDir); +} + // [ ] Add potentially context awareness like in the other commands function registerExportBackupCommand(program, core) { const cmd = program @@ -107,13 +130,14 @@ function registerExportBackupCommand(program, core) { cmd.action( withErrorHandler(async (options) => { - attachCliEventHandlers('export-backup', core, options); - const outputObject = await core.exportBackup({ - branch: options.branch, + await exportWizard({ instance: options.instance, workspace: options.workspace, + branch: options.branch, + core: core, + doLog: options.printOutputDir, + output: options.output, }); - printOutputDir(options.printOutput, outputObject.outputDir); }) ); } diff --git a/packages/cli/src/node-config-storage.ts b/packages/cli/src/node-config-storage.ts index 6536748..e34e2f4 100644 --- a/packages/cli/src/node-config-storage.ts +++ b/packages/cli/src/node-config-storage.ts @@ -237,21 +237,25 @@ export const nodeConfigStorage: ConfigStorage = { async writeFile(filePath, data) { await fs.promises.writeFile(filePath, data); }, - async streamToFile( - destinationPath: string, - source: ReadableStream | NodeJS.ReadableStream - ): Promise { - const dest = fs.createWriteStream(destinationPath, { mode: 0o600 }); + + async streamToFile({ + path, + stream, + }: { + path: string; + stream: ReadableStream | NodeJS.ReadableStream; + }): Promise { + const dest = fs.createWriteStream(path, { mode: 0o600 }); let nodeStream: NodeJS.ReadableStream; // Convert if necessary - if (typeof (source as any).pipe === 'function') { + if (typeof (stream as any).pipe === 'function') { // already a NodeJS stream - nodeStream = source as NodeJS.ReadableStream; + nodeStream = stream as NodeJS.ReadableStream; } else { // WHATWG stream (from fetch in Node 18+) // Can only use fromWeb if available in the environment - nodeStream = Readable.fromWeb(source as any); + nodeStream = Readable.fromWeb(stream as any); } await new Promise((resolve, reject) => { @@ -261,9 +265,11 @@ export const nodeConfigStorage: ConfigStorage = { nodeStream.on('error', (err) => reject(err)); }); }, + async readFile(filePath) { return await fs.promises.readFile(filePath); // returns Buffer }, + async exists(filePath) { try { await fs.promises.access(filePath); diff --git a/packages/core/src/implementations/backups.ts b/packages/core/src/implementations/backups.ts index 62ba8bb..bd7ccc6 100644 --- a/packages/core/src/implementations/backups.ts +++ b/packages/core/src/implementations/backups.ts @@ -1,9 +1,9 @@ -import { replacePlaceholders, joinPath } from '@calycode/utils'; +import { replacePlaceholders, joinPath, dirname } from '@calycode/utils'; /** * Exports a backup and emits events for CLI/UI. */ -async function exportBackupImplementation({ instance, workspace, branch, core }) { +async function exportBackupImplementation({ outputDir, instance, workspace, branch, core }) { core.emit('start', { name: 'export-backup', payload: { instance, workspace, branch }, @@ -35,43 +35,70 @@ async function exportBackupImplementation({ instance, workspace, branch, core }) percent: 15, }); - // Resolve output dir - const outputDir = replacePlaceholders(instanceConfig.backups.output, { - instance: instanceConfig.name, - workspace: workspaceConfig.name, - branch: branchConfig.label, + core.emit('progress', { + name: 'export-backup', + message: 'Requesting backup from Xano API...', + percent: 40, }); - await core.storage.mkdir(outputDir, { recursive: true }); - + const startTime = Date.now(); core.emit('progress', { name: 'export-backup', message: 'Requesting backup from Xano API...', percent: 40, }); - const backupStreamRequest = await fetch( - `${instanceConfig.url}/api:meta/workspace/${workspaceConfig.id}/export`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${await core.loadToken(instanceConfig.name)}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ branch: branchConfig.label }), - } - ); + let backupStreamRequest; + try { + backupStreamRequest = await fetch( + `${instanceConfig.url}/api:meta/workspace/${workspaceConfig.id}/export`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${await core.loadToken(instanceConfig.name)}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ branch: branchConfig.label }), + } + ); + } catch (err) { + core.emit('error', { + error: err, + message: 'Fetch failed', + step: 'fetch', + elapsed: Date.now() - startTime, + }); + throw err; + } - core.emit('progress', { - name: 'export-backup', - message: 'Saving backup file...', - percent: 80, + core.emit('info', { + message: 'Response headers received', + headers: backupStreamRequest.headers, + status: backupStreamRequest.status, + elapsed: Date.now() - startTime, }); const now = new Date(); const ts = now.toISOString().replace(/[:.]/g, '-'); const backupPath = joinPath(outputDir, `backup-${ts}.tar.gz`); - await core.storage.streamToFile(backupStreamRequest.body, backupPath); + + await core.storage.mkdir(outputDir, { recursive: true }); + try { + await core.storage.streamToFile({ path: backupPath, stream: backupStreamRequest.body }); + core.emit('info', { + message: 'Streaming complete', + backupPath, + elapsed: Date.now() - startTime, + }); + } catch (err) { + core.emit('error', { + error: err, + message: 'Streaming to file failed', + step: 'streamToFile', + elapsed: Date.now() - startTime, + }); + throw err; + } core.emit('progress', { name: 'export-backup', diff --git a/packages/core/src/implementations/setup.ts b/packages/core/src/implementations/setup.ts index 855bccb..603e149 100644 --- a/packages/core/src/implementations/setup.ts +++ b/packages/core/src/implementations/setup.ts @@ -81,7 +81,7 @@ export async function setupInstanceImplementation( output: '{@}/{workspace}/{branch}/codegen/{api_group_normalized_name}', }, backups: { - output: '{@}/{workspace}{branch}/backups', + output: '{@}/{workspace}/{branch}/backups', }, registry: { output: '{@}/registry', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 72e86ad..47b672f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -179,8 +179,9 @@ export class Caly extends TypedEmitter { * }); * ``` */ - async exportBackup({ instance, workspace, branch }): Promise> { + async exportBackup({ instance, workspace, branch, outputDir }): Promise> { return exportBackupImplementation({ + outputDir, instance, workspace, branch, diff --git a/packages/types/src/storage/index.ts b/packages/types/src/storage/index.ts index b989d87..5a4433f 100644 --- a/packages/types/src/storage/index.ts +++ b/packages/types/src/storage/index.ts @@ -43,7 +43,13 @@ export interface ConfigStorage { mkdir(path: string, options?: { recursive?: boolean }): Promise; readdir(path: string): Promise; writeFile(path: string, data: string | Uint8Array): Promise; - streamToFile(path: string, stream: ReadableStream | NodeJS.ReadableStream): Promise; + streamToFile({ + path, + stream, + }: { + path: string; + stream: ReadableStream | NodeJS.ReadableStream; + }): Promise; readFile(path: string): Promise; exists(path: string): Promise;