diff --git a/src/commands/hosting.ts b/src/commands/hosting.ts index 02f4b92c..a3116e9d 100644 --- a/src/commands/hosting.ts +++ b/src/commands/hosting.ts @@ -1,9 +1,11 @@ import {red} from 'kleur'; import {logHelpHostingClear} from '../help/hosting.clear.help'; import {logHelpHostingDeploy} from '../help/hosting.deploy.help'; +import {logHelpHostingPrune} from '../help/hosting.prune.help'; import {logHelpHosting} from '../help/hosting.help'; import {clear} from '../services/assets/clear.services'; import {deploy} from '../services/assets/deploy.services'; +import {prune} from '../services/assets/prune.services'; export const hosting = async (args?: string[]) => { const [subCommand] = args ?? []; @@ -15,6 +17,9 @@ export const hosting = async (args?: string[]) => { case 'clear': await clear(args); break; + case 'prune': + await prune(args); + break; default: console.log(red('Unknown subcommand.')); logHelpHosting(args); @@ -31,6 +36,9 @@ export const helpHosting = (args?: string[]) => { case 'clear': logHelpHostingClear(args); break; + case 'prune': + logHelpHostingPrune(args); + break; default: logHelpHosting(args); } diff --git a/src/constants/help.constants.ts b/src/constants/help.constants.ts index d90eee1e..807ed531 100644 --- a/src/constants/help.constants.ts +++ b/src/constants/help.constants.ts @@ -28,6 +28,8 @@ export const CONFIG_INIT_DESCRIPTION = 'Set up your project by creating a config export const HOSTING_DEPLOY_DESCRIPTION = 'Deploy your app to your satellite.'; export const HOSTING_CLEAR_DESCRIPTION = 'Remove frontend files (JS, HTML, CSS, etc.) from your satellite.'; +export const HOSTING_PRUNE_DESCRIPTION = + 'Remove stale frontend files from your satellite that are no longer in your build output.'; export const EMULATOR_START_DESCRIPTION = 'Start the emulator for local development.'; export const EMULATOR_WAIT_DESCRIPTION = 'Wait until the emulator is ready.'; diff --git a/src/help/hosting.help.ts b/src/help/hosting.help.ts index b28bdcba..e9155a74 100644 --- a/src/help/hosting.help.ts +++ b/src/help/hosting.help.ts @@ -2,7 +2,8 @@ import {cyan, green, magenta, yellow} from 'kleur'; import { HOSTING_CLEAR_DESCRIPTION, HOSTING_DEPLOY_DESCRIPTION, - HOSTING_DESCRIPTION + HOSTING_DESCRIPTION, + HOSTING_PRUNE_DESCRIPTION } from '../constants/help.constants'; import {helpOutput} from './common.help'; import {TITLE} from './help'; @@ -11,7 +12,8 @@ const usage = `Usage: ${green('juno')} ${cyan('hosting')} ${magenta(' { + console.log(helpOutput(args) === 'doc' ? doc : help); +}; diff --git a/src/services/assets/prune.services.ts b/src/services/assets/prune.services.ts new file mode 100644 index 00000000..d54fec72 --- /dev/null +++ b/src/services/assets/prune.services.ts @@ -0,0 +1,154 @@ +import { + COLLECTION_DAPP, + DEPLOY_DEFAULT_IGNORE, + DEPLOY_DEFAULT_SOURCE, + files as listFiles, + hasArgs +} from '@junobuild/cli-tools'; +import {deleteManyAssets, type Asset} from '@junobuild/core'; +import {minimatch} from 'minimatch'; +import {green, red, yellow} from 'kleur'; +import {join} from 'node:path'; +import ora from 'ora'; +import {noJunoConfig} from '../../configs/juno.config'; +import {assertConfigAndLoadSatelliteContext} from '../../utils/juno.config.utils'; +import {consoleNoConfigFound} from '../../utils/msg.utils'; +import {listAssets} from './_deploy/deploy.list.services'; + +/** + * Converts an absolute file path to its fullPath form. + * e.g. "/path/to/build/index.html" -> "/index.html" + */ +const toFullPath = (file: string, sourceAbsolutePath: string): string => + file.replace(sourceAbsolutePath, '').replace(/\\/g, '/'); + +/** + * Returns true if the file should be excluded based on the ignore patterns. + */ +const isIgnored = (file: string, ignore: string[]): boolean => + ignore.some((pattern) => minimatch(file, pattern, {matchBase: true})); + +/** + * Scans the local source directory and returns a Set of fullPaths that are present. + * Throws if the directory cannot be read. + */ +const buildLocalPaths = (sourceAbsolutePath: string, ignore: string[]): Set => { + const allFiles = listFiles(sourceAbsolutePath); + const filteredFiles = allFiles.filter((file) => !isIgnored(file, ignore)); + return new Set(filteredFiles.map((file) => toFullPath(file, sourceAbsolutePath))); +}; + +export const prune = async (args?: string[]) => { + if (await noJunoConfig()) { + consoleNoConfigFound(); + return; + } + + await executePrune(args); +}; + +const executePrune = async (args?: string[]) => { + const dryRun = hasArgs({args, options: ['--dry-run']}); + + const {satellite, satelliteConfig} = await assertConfigAndLoadSatelliteContext(); + + const source: string = satelliteConfig.source ?? DEPLOY_DEFAULT_SOURCE; + const ignore: string[] = satelliteConfig.ignore ?? DEPLOY_DEFAULT_IGNORE; + + const sourceAbsolutePath = join(process.cwd(), source); + + // 1. Scan local build output + const scanSpinner = ora('Scanning local build output...').start(); + const localPaths = scanLocalFiles({scanSpinner, sourceAbsolutePath, ignore, source}); + + // 2. Fetch all live assets (paginated) + const fetchSpinner = ora('Fetching live assets from satellite...').start(); + const liveAssets = await fetchLiveAssets({fetchSpinner, satellite}); + + // 3. Compute stale = live_assets − local_files + const stale = liveAssets.filter(({fullPath}) => !localPaths.has(fullPath)); + + if (stale.length === 0) { + console.log(`${green('✔')} No stale assets found. Satellite is already clean.`); + return; + } + + // 4. Report + console.log(`\nFound ${yellow(String(stale.length))} stale asset(s):`); + for (const {fullPath} of stale) { + console.log(` ${red('−')} ${fullPath}`); + } + + if (dryRun) { + console.log(`\n${yellow('[dry-run]')} No files have been deleted.`); + return; + } + + // 5. Delete stale assets + await pruneStaleAssets({stale, satellite}); +}; + +const scanLocalFiles = ({ + scanSpinner, + sourceAbsolutePath, + ignore, + source +}: { + scanSpinner: ReturnType; + sourceAbsolutePath: string; + ignore: string[]; + source: string; +}): Set => { + try { + const paths = buildLocalPaths(sourceAbsolutePath, ignore); + scanSpinner.stop(); + return paths; + } catch (err: unknown) { + scanSpinner.stop(); + console.log( + `${red('Cannot scan source directory.')} Is "${source}" built and configured in juno.config?` + ); + throw err; + } +}; + +const fetchLiveAssets = async ({ + fetchSpinner, + satellite +}: { + fetchSpinner: ReturnType; + satellite: Parameters[0]['satellite']; +}): Promise => { + try { + const assets = await listAssets({satellite}); + fetchSpinner.stop(); + return assets; + } catch (err: unknown) { + fetchSpinner.stop(); + throw err; + } +}; + +const pruneStaleAssets = async ({ + stale, + satellite +}: { + stale: Asset[]; + satellite: Parameters[0]['satellite']; +}): Promise => { + const deleteSpinner = ora(`Deleting ${stale.length} stale asset(s)...`).start(); + try { + await deleteManyAssets({ + assets: stale.map(({fullPath}) => ({ + collection: COLLECTION_DAPP, + fullPath + })), + satellite + }); + deleteSpinner.stop(); + console.log(`\n${green('✔')} Pruned ${stale.length} stale asset(s).`); + } catch (err: unknown) { + deleteSpinner.stop(); + throw err; + } +};