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
8 changes: 8 additions & 0 deletions src/commands/hosting.ts
Original file line number Diff line number Diff line change
@@ -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 ?? [];
Expand All @@ -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);
Expand All @@ -31,6 +36,9 @@ export const helpHosting = (args?: string[]) => {
case 'clear':
logHelpHostingClear(args);
break;
case 'prune':
logHelpHostingPrune(args);
break;
default:
logHelpHosting(args);
}
Expand Down
2 changes: 2 additions & 0 deletions src/constants/help.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand Down
6 changes: 4 additions & 2 deletions src/help/hosting.help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -11,7 +12,8 @@ const usage = `Usage: ${green('juno')} ${cyan('hosting')} ${magenta('<subcommand

Subcommands:
${magenta('clear')} ${HOSTING_CLEAR_DESCRIPTION}
${magenta('deploy')} ${HOSTING_DEPLOY_DESCRIPTION}`;
${magenta('deploy')} ${HOSTING_DEPLOY_DESCRIPTION}
${magenta('prune')} ${HOSTING_PRUNE_DESCRIPTION}`;

const doc = `${HOSTING_DESCRIPTION}

Expand Down
33 changes: 33 additions & 0 deletions src/help/hosting.prune.help.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {cyan, green, magenta, yellow} from 'kleur';
import {
HOSTING_PRUNE_DESCRIPTION,
OPTION_HELP,
OPTIONS_ENV
} from '../constants/help.constants';
import {helpOutput} from './common.help';
import {TITLE} from './help';

const usage = `Usage: ${green('juno')} ${cyan('hosting')} ${magenta('prune')} ${yellow('[options]')}

Options:
${yellow('--dry-run')} List stale files that would be deleted without actually deleting them.
${OPTIONS_ENV}
${OPTION_HELP}`;

const doc = `${HOSTING_PRUNE_DESCRIPTION}

\`\`\`
${usage}
\`\`\`
`;

const help = `${TITLE}

${HOSTING_PRUNE_DESCRIPTION}

${usage}
`;

export const logHelpHostingPrune = (args?: string[]) => {
console.log(helpOutput(args) === 'doc' ? doc : help);
};
154 changes: 154 additions & 0 deletions src/services/assets/prune.services.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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<typeof ora>;
sourceAbsolutePath: string;
ignore: string[];
source: string;
}): Set<string> => {
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<typeof ora>;
satellite: Parameters<typeof listAssets>[0]['satellite'];
}): Promise<Asset[]> => {
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<typeof listAssets>[0]['satellite'];
}): Promise<void> => {
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;
}
};
Loading