diff --git a/src/commands/changes.ts b/src/commands/changes.ts new file mode 100644 index 00000000..21abe70b --- /dev/null +++ b/src/commands/changes.ts @@ -0,0 +1,45 @@ +import {red} from 'kleur'; +import {logHelpChangesApply} from '../help/changes.apply.help'; +import {logHelpChanges} from '../help/changes.help'; +import {logHelpChangesList} from '../help/changes.list.help'; +import {logHelpChangesReject} from '../help/changes.reject.help'; +import {applyChanges} from '../services/changes/changes.apply.services'; +import {listChanges} from '../services/changes/changes.list.services'; +import {rejectChanges} from '../services/changes/changes.reject.services'; + +export const changes = async (args?: string[]) => { + const [subCommand] = args ?? []; + + switch (subCommand) { + case 'list': + await listChanges(args); + break; + case 'apply': + await applyChanges(args); + break; + case 'reject': + await rejectChanges(args); + break; + default: + console.log(red('Unknown subcommand.')); + logHelpChanges(); + } +}; + +export const helpChanges = (args?: string[]) => { + const [subCommand] = args ?? []; + + switch (subCommand) { + case 'list': + logHelpChangesList(args); + break; + case 'apply': + logHelpChangesApply(args); + break; + case 'reject': + logHelpChangesReject(args); + break; + default: + logHelpChanges(args); + } +}; diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 0022dd49..fb547555 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -37,14 +37,17 @@ export const deploy = async (args?: string[]) => { }; const deployWithProposal = async ({args, clearOption}: {args?: string[]; clearOption: boolean}) => { - const noCommit = hasArgs({args, options: ['-n', '--no-commit']}); + const noCommit = hasArgs({args, options: ['-n', '--no-apply']}); const deployFn = async ({ deploy, satellite }: DeployFnParams): Promise => await cliDeployWithProposal({ - deploy, + deploy: { + ...deploy, + includeAllFiles: clearOption + }, proposal: { clearAssets: clearOption, autoCommit: !noCommit, diff --git a/src/constants/help.constants.ts b/src/constants/help.constants.ts index 688dfee7..95b6cefe 100644 --- a/src/constants/help.constants.ts +++ b/src/constants/help.constants.ts @@ -1,5 +1,6 @@ import {magenta} from 'kleur'; +export const CHANGES_DESCRIPTION = 'Review and apply changes submitted to your module.'; export const CLEAR_DESCRIPTION = 'Clear existing app code by removing JavaScript, HTML, CSS, and other files from your satellite.'; export const CONFIG_DESCRIPTION = 'Apply configuration to satellite.'; @@ -30,3 +31,7 @@ export const DEV_BUILD_NOTES = `- If no language is provided, the CLI attempts t - Language can be shortened to ${magenta('rs')} for Rust, ${magenta('ts')} for TypeScript and ${magenta('mjs')} for JavaScript. - The path option maps to ${magenta('--manifest-path')} for Rust (Cargo) or to the source file for TypeScript and JavaScript (e.g. ${magenta('index.ts')} or ${magenta('index.mjs')}). - The watch option rebuilds when source files change, with a default debounce delay of 10 seconds; optionally, pass a delay in milliseconds.`; + +export const CHANGES_LIST_DESCRIPTION = 'List all submitted or applied changes.'; +export const CHANGES_APPLY_DESCRIPTION = 'Apply a submitted change.'; +export const CHANGES_REJECT_DESCRIPTION = 'Reject a change.'; diff --git a/src/help/changes.apply.help.ts b/src/help/changes.apply.help.ts new file mode 100644 index 00000000..d79c0bde --- /dev/null +++ b/src/help/changes.apply.help.ts @@ -0,0 +1,29 @@ +import {cyan, green, magenta, yellow} from 'kleur'; +import {CHANGES_APPLY_DESCRIPTION} from '../constants/help.constants'; +import {helpOutput} from './common.help'; +import {TITLE} from './help'; + +const usage = `Usage: ${green('juno')} ${cyan('changes')} ${magenta('apply')} ${yellow('[options]')} + +Options: + ${yellow('-i, --id')} The ID of the change to apply. + ${yellow('-s, --hash')} The expected hash of all included changes (for verification). + ${yellow('-h, --help')} Output usage information.`; + +const doc = `${CHANGES_APPLY_DESCRIPTION} + +\`\`\` +${usage} +\`\`\` +`; + +const help = `${TITLE} + +${CHANGES_APPLY_DESCRIPTION} + +${usage} +`; + +export const logHelpChangesApply = (args?: string[]) => { + console.log(helpOutput(args) === 'doc' ? doc : help); +}; diff --git a/src/help/changes.help.ts b/src/help/changes.help.ts new file mode 100644 index 00000000..3f3d8b13 --- /dev/null +++ b/src/help/changes.help.ts @@ -0,0 +1,38 @@ +import {cyan, green, magenta, yellow} from 'kleur'; +import { + CHANGES_APPLY_DESCRIPTION, + CHANGES_DESCRIPTION, + CHANGES_LIST_DESCRIPTION, + CHANGES_REJECT_DESCRIPTION +} from '../constants/help.constants'; +import {helpOutput} from './common.help'; +import {TITLE} from './help'; + +const helpChangesList = `${magenta('list')} ${CHANGES_LIST_DESCRIPTION}`; +const helpChangesApply = `${magenta('apply')} ${CHANGES_APPLY_DESCRIPTION}`; +const helpChangesReject = `${magenta('reject')} ${CHANGES_REJECT_DESCRIPTION}`; + +const usage = `Usage: ${green('juno')} ${cyan('changes')} ${magenta('')} ${yellow('[options]')} + +Subcommands: + ${helpChangesApply} + ${helpChangesList} + ${helpChangesReject}`; + +const doc = `${CHANGES_DESCRIPTION} + +\`\`\` +${usage} +\`\`\` +`; + +const help = `${TITLE} + +${CHANGES_DESCRIPTION} + +${usage} +`; + +export const logHelpChanges = (args?: string[]) => { + console.log(helpOutput(args) === 'doc' ? doc : help); +}; diff --git a/src/help/changes.list.help.ts b/src/help/changes.list.help.ts new file mode 100644 index 00000000..69cf217b --- /dev/null +++ b/src/help/changes.list.help.ts @@ -0,0 +1,29 @@ +import {cyan, green, magenta, yellow} from 'kleur'; +import {CHANGES_LIST_DESCRIPTION} from '../constants/help.constants'; +import {helpOutput} from './common.help'; +import {TITLE} from './help'; + +const usage = `Usage: ${green('juno')} ${cyan('changes')} ${magenta('list')} ${yellow('[options]')} + +Options: + ${yellow('-a, --all')} Search through all changes, not just the 100 most recent. + ${yellow('-e, --every')} Include changes of any status (default is only submitted ones). + ${yellow('-h, --help')} Output usage information.`; + +const doc = `${CHANGES_LIST_DESCRIPTION} + +\`\`\` +${usage} +\`\`\` +`; + +const help = `${TITLE} + +${CHANGES_LIST_DESCRIPTION} + +${usage} +`; + +export const logHelpChangesList = (args?: string[]) => { + console.log(helpOutput(args) === 'doc' ? doc : help); +}; diff --git a/src/help/changes.reject.help.ts b/src/help/changes.reject.help.ts new file mode 100644 index 00000000..f7ca41c4 --- /dev/null +++ b/src/help/changes.reject.help.ts @@ -0,0 +1,29 @@ +import {cyan, green, magenta, yellow} from 'kleur'; +import {CHANGES_REJECT_DESCRIPTION} from '../constants/help.constants'; +import {helpOutput} from './common.help'; +import {TITLE} from './help'; + +const usage = `Usage: ${green('juno')} ${cyan('changes')} ${magenta('reject')} ${yellow('[options]')} + +Options: + ${yellow('-i, --id')} The ID of the change to reject. + ${yellow('-s, --hash')} The expected hash of all included changes (for verification). + ${yellow('-h, --help')} Output usage information.`; + +const doc = `${CHANGES_REJECT_DESCRIPTION} + +\`\`\` +${usage} +\`\`\` +`; + +const help = `${TITLE} + +${CHANGES_REJECT_DESCRIPTION} + +${usage} +`; + +export const logHelpChangesReject = (args?: string[]) => { + console.log(helpOutput(args) === 'doc' ? doc : help); +}; diff --git a/src/help/deploy.help.ts b/src/help/deploy.help.ts index c66ef431..f8ae3070 100644 --- a/src/help/deploy.help.ts +++ b/src/help/deploy.help.ts @@ -7,7 +7,7 @@ const usage = `Usage: ${green('juno')} ${cyan('deploy')} ${yellow('[options]')} Options: ${yellow('-c, --clear')} Clear existing app files before proceeding with deployment. - ${yellow('-n, --no-commit')} Submit the deployment as a change but do not apply it yet. + ${yellow('-n, --no-apply')} Submit the deployment as a change but do not apply it yet. ${yellow('-i, --immediate')} Deploy files instantly (bypasses the change workflow). ${helpMode} ${yellow('-h, --help')} Output usage information.`; diff --git a/src/index.ts b/src/index.ts index 9c73a78d..df629255 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import {hasArgs} from '@junobuild/cli-tools'; import {red} from 'kleur'; import {login, logout} from './commands/auth'; +import {changes, helpChanges} from './commands/changes'; import {clear} from './commands/clear'; import {config} from './commands/config'; import {deploy} from './commands/deploy'; @@ -103,6 +104,9 @@ export const run = async () => { case 'start': logHelpStart(args); break; + case 'changes': + helpChanges(args); + break; default: console.log(red('Unknown command.')); console.log(help); @@ -156,6 +160,9 @@ export const run = async () => { case 'snapshot': await snapshot(args); break; + case 'changes': + await changes(args); + break; case 'help': console.log(help); break; diff --git a/src/services/changes/changes.apply.services.ts b/src/services/changes/changes.apply.services.ts new file mode 100644 index 00000000..e4c92912 --- /dev/null +++ b/src/services/changes/changes.apply.services.ts @@ -0,0 +1,29 @@ +import {hexStringToUint8Array} from '@dfinity/utils'; +import {commitProposal} from '@junobuild/cdn'; +import ora from 'ora'; +import {readChangesIdAndHash} from '../../utils/changes.utils'; +import {assertConfigAndLoadSatelliteContext} from '../../utils/satellite.utils'; + +export const applyChanges = async (args?: string[]) => { + const {satellite} = await assertConfigAndLoadSatelliteContext(args); + + const {proposalId, hash} = readChangesIdAndHash(args); + + const spinner = ora('Applying...').start(); + + try { + await commitProposal({ + cdn: { + satellite + }, + proposal: { + proposal_id: proposalId, + sha256: hexStringToUint8Array(hash) + } + }); + + console.log(`\n🎯 Change ID ${proposalId} applied.`); + } finally { + spinner.stop(); + } +}; diff --git a/src/services/changes/changes.list.services.ts b/src/services/changes/changes.list.services.ts new file mode 100644 index 00000000..18f5acf5 --- /dev/null +++ b/src/services/changes/changes.list.services.ts @@ -0,0 +1,83 @@ +import {fromNullable, nonNullish, toNullable, uint8ArrayToHexString} from '@dfinity/utils'; +import {listProposals as listProposalsLib, type Proposal, type ProposalKey} from '@junobuild/cdn'; +import {hasArgs} from '@junobuild/cli-tools'; +import {type SatelliteParametersWithId} from '../../types/satellite'; +import {formatDate} from '../../utils/format.utils'; +import {assertConfigAndLoadSatelliteContext} from '../../utils/satellite.utils'; + +export const listChanges = async (args?: string[]) => { + const {satellite} = await assertConfigAndLoadSatelliteContext(args); + + const all = hasArgs({args, options: ['-a', '--all']}); + const every = hasArgs({args, options: ['-e', '--every']}); + + const items = await listProposals({ + satellite, + traverseAll: all + }); + + const changes = items + .filter(([_, {status}]) => 'Open' in status || every) + .reduce>( + (acc, [{proposal_id}, {sha256, created_at}]) => { + const hash: Uint8Array | number[] | undefined = fromNullable(sha256); + + return { + ...acc, + [`${proposal_id}`]: { + hash: nonNullish(hash) ? uint8ArrayToHexString(hash) : '', + created_at: formatDate(new Date(Number(created_at / 1_000_000n))) + } + }; + }, + {} + ); + + if (Object.keys(changes).length === 0) { + console.log('There are no open changes right now.'); + return; + } + + console.table(changes); +}; + +const listProposals = async ({ + startAfter, + satellite, + traverseAll +}: { + startAfter?: bigint; + satellite: SatelliteParametersWithId; + traverseAll: boolean; +}): Promise> => { + const {items, items_length, matches_length} = await listProposalsLib({ + cdn: {satellite}, + filter: { + order: toNullable({ + desc: true + }), + paginate: nonNullish(startAfter) + ? toNullable({ + start_after: toNullable(startAfter), + limit: toNullable() + }) + : toNullable() + } + }); + + const last = (elements: T[]): T | undefined => { + const {length, [length - 1]: last} = elements; + return last; + }; + + if (items_length > matches_length && traverseAll) { + const nextItems = await listProposals({ + startAfter: last(items)?.[0].proposal_id, + satellite, + traverseAll + }); + return [...items, ...nextItems]; + } + + return items; +}; diff --git a/src/services/changes/changes.reject.services.ts b/src/services/changes/changes.reject.services.ts new file mode 100644 index 00000000..d63b40c7 --- /dev/null +++ b/src/services/changes/changes.reject.services.ts @@ -0,0 +1,29 @@ +import {hexStringToUint8Array} from '@dfinity/utils'; +import {rejectProposal} from '@junobuild/cdn'; +import ora from 'ora'; +import {readChangesIdAndHash} from '../../utils/changes.utils'; +import {assertConfigAndLoadSatelliteContext} from '../../utils/satellite.utils'; + +export const rejectChanges = async (args?: string[]) => { + const {satellite} = await assertConfigAndLoadSatelliteContext(args); + + const {proposalId, hash} = readChangesIdAndHash(args); + + const spinner = ora('Rejecting...').start(); + + try { + await rejectProposal({ + cdn: { + satellite + }, + proposal: { + proposal_id: proposalId, + sha256: hexStringToUint8Array(hash) + } + }); + + console.log(`\n🚫 Change ID ${proposalId} rejected.`); + } finally { + spinner.stop(); + } +}; diff --git a/src/utils/changes.utils.ts b/src/utils/changes.utils.ts new file mode 100644 index 00000000..bbe1fb81 --- /dev/null +++ b/src/utils/changes.utils.ts @@ -0,0 +1,28 @@ +import {assertNonNullish} from '@dfinity/utils'; +import {nextArg} from '@junobuild/cli-tools'; + +export const readChangesIdAndHash = (args?: string[]): {proposalId: bigint; hash: string} => { + const id = nextArg({args, option: '-i'}) ?? nextArg({args, option: '--id'}); + + assertNonNullish(id, 'An id must be provided'); + + const toBigInt = (): bigint => { + try { + return BigInt(id); + } catch (_err: unknown) { + console.error('The id must be a valid number.'); + process.exit(1); + } + }; + + const proposalId = toBigInt(); + + const hash = nextArg({args, option: '-s'}) ?? nextArg({args, option: '--hash'}); + + assertNonNullish(hash, 'A hash must be provided'); + + return { + proposalId, + hash + }; +}; diff --git a/src/utils/format.utils.ts b/src/utils/format.utils.ts index 6620b25c..7e3c3b21 100644 --- a/src/utils/format.utils.ts +++ b/src/utils/format.utils.ts @@ -17,3 +17,14 @@ export const formatTime = (date: Date = new Date()): string => { hour12: false }).format(date); }; + +export const formatDate = (date: Date = new Date()): string => { + return new Intl.DateTimeFormat('en', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false + }).format(date); +};