diff --git a/packages/cli-tools/src/commands/prune.ts b/packages/cli-tools/src/commands/prune.ts new file mode 100644 index 000000000..6fd6bff44 --- /dev/null +++ b/packages/cli-tools/src/commands/prune.ts @@ -0,0 +1,51 @@ +import ora from 'ora'; +import {preparePrune as preparePruneServices} from '../services/prune.prepare.services'; +import {prune as pruneServices} from '../services/prune.services'; +import type {PruneFilesFn, PruneFileStorage, PruneParams, PruneResult} from '../types/prune'; + +export const prune = async ({ + params, + pruneFn +}: { + params: PruneParams; + pruneFn: PruneFilesFn; +}): Promise => { + const prepareResult = await preparePrune(params); + + if (prepareResult.result === 'skipped') { + return {result: 'skipped'}; + } + + const {files} = prepareResult; + + await pruneServices({files, pruneFn}); + + return {result: 'pruned', files}; +}; + +const preparePrune = async ( + params: Omit +): Promise<{result: 'skipped'} | {result: 'to-prune'; files: PruneFileStorage[]}> => { + const spinner = ora('Preparing deploy...').start(); + + try { + const {files: sourceFiles} = await preparePruneServices(params); + + spinner.stop(); + + if (sourceFiles.length === 0) { + console.log(''); + console.log('šŸ‘ No stale assets found. Satellite is already clean.'); + + return {result: 'skipped'}; + } + + return { + result: 'to-prune', + files: sourceFiles + }; + } catch (err: unknown) { + spinner.stop(); + throw err; + } +}; diff --git a/packages/cli-tools/src/index.ts b/packages/cli-tools/src/index.ts index c29a11a81..62031cbcd 100644 --- a/packages/cli-tools/src/index.ts +++ b/packages/cli-tools/src/index.ts @@ -1,12 +1,14 @@ export * from './commands/build'; export * from './commands/deploy'; export * from './commands/generate'; +export * from './commands/prune'; export * from './commands/publish'; export * from './constants/deploy.constants'; export {ListAssets} from './types/assets'; export type * from './types/deploy'; export type * from './types/pkg'; export type * from './types/proposal'; +export type * from './types/prune'; export type * from './types/publish'; export * from './utils/args.utils'; export * from './utils/cmd.utils'; diff --git a/packages/cli-tools/src/services/prune.prepare.services.ts b/packages/cli-tools/src/services/prune.prepare.services.ts new file mode 100644 index 000000000..a40fa4f64 --- /dev/null +++ b/packages/cli-tools/src/services/prune.prepare.services.ts @@ -0,0 +1,43 @@ +import type {CliConfig} from '@junobuild/config'; +import type {Asset} from '@junobuild/storage'; +import {join} from 'node:path'; +import {DEPLOY_DEFAULT_IGNORE, DEPLOY_DEFAULT_SOURCE} from '../constants/deploy.constants'; +import type {ListAssets} from '../types/assets'; +import type {PreparePruneOptions, PruneFileStorage} from '../types/prune'; +import {listSourceFilesForPrune} from '../utils/prune.utils'; + +export const preparePrune = async ({ + config, + listAssets, + assertSourceDirExists +}: { + config: CliConfig; + listAssets: ListAssets; +} & PreparePruneOptions): Promise<{files: PruneFileStorage[]}> => { + const {source = DEPLOY_DEFAULT_SOURCE, ignore = DEPLOY_DEFAULT_IGNORE} = config; + + const sourceAbsolutePath = join(process.cwd(), source); + + assertSourceDirExists?.(sourceAbsolutePath); + + // 1. Scan local build output + const localPaths = listSourceFilesForPrune({sourceAbsolutePath, ignore}); + + const stale = await filterFilesToPrune({localPaths, listAssets}); + + return {files: stale.map(({fullPath}) => ({fullPath}))}; +}; + +const filterFilesToPrune = async ({ + localPaths, + listAssets +}: { + localPaths: Set; + listAssets: ListAssets; +}): Promise => { + // 2. Fetch all live assets (paginated) + const existingAssets = await listAssets({}); + + // 3. Compute stale = live_assets āˆ’ local_files + return existingAssets.filter(({fullPath}) => !localPaths.has(fullPath)); +}; diff --git a/packages/cli-tools/src/services/prune.services.ts b/packages/cli-tools/src/services/prune.services.ts new file mode 100644 index 000000000..d7362a312 --- /dev/null +++ b/packages/cli-tools/src/services/prune.services.ts @@ -0,0 +1,20 @@ +import ora from 'ora'; +import type {PruneFilesFn, PruneFileStorage} from '../types/prune'; + +export const prune = async ({ + files, + pruneFn +}: { + files: PruneFileStorage[]; + pruneFn: PruneFilesFn; +}) => { + const deleteSpinner = ora(`Deleting ${files.length} stale asset(s)...`).start(); + + try { + await pruneFn({files}); + } finally { + deleteSpinner.stop(); + } + + console.log(`\nāœ” Pruned ${files.length} stale asset(s).`); +}; diff --git a/packages/cli-tools/src/types/prune.ts b/packages/cli-tools/src/types/prune.ts new file mode 100644 index 000000000..18e65a8a4 --- /dev/null +++ b/packages/cli-tools/src/types/prune.ts @@ -0,0 +1,14 @@ +import type {AssetsParams, PrepareAssetsOptions} from './assets'; +import type {PrepareDeployOptions} from './deploy'; + +export type PreparePruneOptions = PrepareAssetsOptions; + +export type PruneParams = PrepareDeployOptions & AssetsParams; + +export interface PruneFileStorage { + fullPath: string; +} + +export type PruneFilesFn = (params: {files: PruneFileStorage[]}) => Promise; + +export type PruneResult = {result: 'pruned'; files: PruneFileStorage[]} | {result: 'skipped'}; diff --git a/packages/cli-tools/src/utils/prune.utils.ts b/packages/cli-tools/src/utils/prune.utils.ts new file mode 100644 index 000000000..f8d5100d8 --- /dev/null +++ b/packages/cli-tools/src/utils/prune.utils.ts @@ -0,0 +1,28 @@ +import type {SatelliteConfig} from '@junobuild/config'; +import {minimatch} from 'minimatch'; +import {fullPath} from './assets.utils'; +import {files} from './fs.utils'; + +/** + * Scans the local source directory and returns a Set of fullPaths that are present. + * Throws if the directory cannot be read. + */ +export const listSourceFilesForPrune = ({ + sourceAbsolutePath, + ignore +}: {sourceAbsolutePath: string} & Required>): Set => { + const allFiles = files(sourceAbsolutePath); + const filteredFiles = allFiles.filter((file) => shouldBeIncluded({file, ignore})); + return new Set(filteredFiles.map((file) => fullPath({file, sourceAbsolutePath}))); +}; + +/** + * Returns true if the file should be excluded based on the ignore patterns. + */ +const isIgnored = ({file, ignore}: {file: string; ignore: string[]}): boolean => + ignore.some((pattern) => minimatch(file, pattern, {matchBase: true})); + +/** + * Returns true if the file should be included for deletion. + */ +const shouldBeIncluded = (params: {file: string; ignore: string[]}): boolean => !isIgnored(params);