Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/cli-tools/src/commands/prune.ts
Original file line number Diff line number Diff line change
@@ -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<PruneResult> => {
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<PruneParams, 'uploadFn'>
): 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;
}
};
2 changes: 2 additions & 0 deletions packages/cli-tools/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
43 changes: 43 additions & 0 deletions packages/cli-tools/src/services/prune.prepare.services.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
listAssets: ListAssets;
}): Promise<Asset[]> => {
// 2. Fetch all live assets (paginated)
const existingAssets = await listAssets({});

// 3. Compute stale = live_assets − local_files
return existingAssets.filter(({fullPath}) => !localPaths.has(fullPath));
};
20 changes: 20 additions & 0 deletions packages/cli-tools/src/services/prune.services.ts
Original file line number Diff line number Diff line change
@@ -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).`);
};
14 changes: 14 additions & 0 deletions packages/cli-tools/src/types/prune.ts
Original file line number Diff line number Diff line change
@@ -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<void>;

export type PruneResult = {result: 'pruned'; files: PruneFileStorage[]} | {result: 'skipped'};
28 changes: 28 additions & 0 deletions packages/cli-tools/src/utils/prune.utils.ts
Original file line number Diff line number Diff line change
@@ -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<Pick<SatelliteConfig, 'ignore'>>): Set<string> => {
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);
Loading