diff --git a/src/commands/auth.ts b/src/commands/auth.ts index caa8e27a..d1c94acc 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,11 +1,14 @@ import {Ed25519KeyIdentity} from '@dfinity/identity'; import {isNullish} from '@dfinity/utils'; -import {assertAnswerCtrlC} from '@junobuild/cli-tools'; -import {green} from 'kleur'; +import {assertAnswerCtrlC, hasArgs} from '@junobuild/cli-tools'; +import {green, red} from 'kleur'; import prompts from 'prompts'; import {clearCliConfig, getToken} from '../configs/cli.config'; -import {login as consoleLogin} from '../services/auth/login.services'; +import {DEV} from '../env'; +import {loginEmulatorOnly} from '../services/auth/login.emulator.services'; +import {login as loginServices} from '../services/auth/login.services'; import {reuseController} from '../services/controllers.services'; +import {confirmAndExit} from '../utils/prompt.utils'; export const logout = async () => { await clearCliConfig(); @@ -14,10 +17,19 @@ export const logout = async () => { }; export const login = async (args?: string[]) => { + if (hasArgs({args, options: ['-e', '--emulator']})) { + await emulatorLogin(); + return; + } + + await consoleLogin(args); +}; + +const consoleLogin = async (args?: string[]) => { const token = await getToken(); if (isNullish(token)) { - await consoleLogin(args); + await loginServices(args); return; } @@ -37,9 +49,32 @@ export const login = async (args?: string[]) => { assertAnswerCtrlC(action); if (action === 'login') { - await consoleLogin(args); + await loginServices(args); return; } await reuseController(identity.getPrincipal()); }; + +const emulatorLogin = async () => { + if (!DEV) { + console.log(red('The login option --emulator is only supported in development mode.')); + return; + } + + const token = await getToken(); + + if (isNullish(token)) { + await loginEmulatorOnly(); + return; + } + + const identity = Ed25519KeyIdentity.fromParsedJson(token); + console.log(`šŸ” Your terminal already has access: ${green(identity.getPrincipal().toText())}\n`); + + await confirmAndExit( + 'Would you like to overwrite the saved development authentication on this device' + ); + + await loginEmulatorOnly(); +}; diff --git a/src/help/login.help.ts b/src/help/login.help.ts index b91ab485..3c64c7fa 100644 --- a/src/help/login.help.ts +++ b/src/help/login.help.ts @@ -7,6 +7,7 @@ const usage = `Usage: ${green('juno')} ${cyan('login')} ${yellow('[options]')} Options: ${yellow('-b, --browser')} A particular browser to open. supported: chrome|firefox|edge. + ${yellow('-e, --emulator')} Skips the Console UI and logs in your terminal with the emulator (āš ļø local development only). ${OPTIONS_CONFIG} ${OPTION_HELP}`; diff --git a/src/services/auth/login.emulator.services.ts b/src/services/auth/login.emulator.services.ts new file mode 100644 index 00000000..3bac82db --- /dev/null +++ b/src/services/auth/login.emulator.services.ts @@ -0,0 +1,77 @@ +import {notEmptyString} from '@dfinity/utils'; +import {type PrincipalText} from '@dfinity/zod-schemas'; +import {green, red} from 'kleur'; +import ora from 'ora'; +import {saveCliConfig} from '../../configs/cli.config'; +import {readEmulatorConfig} from '../../configs/emulator.config'; +import {readJunoConfig} from '../../configs/juno.config'; +import {ENV} from '../../env'; +import {generateToken} from '../../utils/auth.utils'; +import {assertConfigAndReadSatelliteId} from '../../utils/satellite.utils'; +import {dispatchRequest} from '../emulator/emulator.admin.services'; + +export const loginEmulatorOnly = async () => { + const spinner = ora('Granting terminal access...').start(); + + try { + const result = await loginEmulator(); + + spinner.stop(); + + if (result.status === 'validation-error') { + return; + } + + if (result.status === 'error') { + console.log( + red( + `\nUnable to register your terminal as an access key for the Satellite running in the emulator.` + ) + ); + return; + } + + console.log( + `\nšŸ”“ Your terminal is authenticated with admin access as: ${green(result.principal)}` + ); + } catch (err: unknown) { + spinner.stop(); + throw err; + } +}; + +const loginEmulator = async (): Promise< + {status: 'success'; principal: PrincipalText} | {status: 'error'} | {status: 'validation-error'} +> => { + // We read directly the Juno config because we cannot load an actor at this point as we are login in. + // i.e. we cannot use assertConfigAndLoadSatelliteContext + const {satellite: satelliteConfig} = await readJunoConfig(ENV); + const {satelliteId} = assertConfigAndReadSatelliteId({satellite: satelliteConfig, env: ENV}); + + const parsedResult = await readEmulatorConfig(); + + if (!parsedResult.success) { + return {status: 'validation-error'}; + } + + const {principal, token} = generateToken(); + + const {result} = await dispatchRequest({ + config: parsedResult.config, + request: `satellite/controller/?id=${principal}&satelliteId=${satelliteId}${notEmptyString(ENV.profile) ? `&profile=${encodeURIComponent(ENV.profile)}` : ''}`, + timeout: 10000 + }); + + if (result !== 'ok') { + return {status: 'error'}; + } + + await saveCliConfig({ + token, + satellites: [{p: satelliteId, n: ''}], + orbiters: null, + missionControl: null + }); + + return {status: 'success', principal}; +}; diff --git a/src/services/auth/login.services.ts b/src/services/auth/login.services.ts index 5b9a8ebb..84770e8f 100644 --- a/src/services/auth/login.services.ts +++ b/src/services/auth/login.services.ts @@ -1,4 +1,3 @@ -import {Ed25519KeyIdentity} from '@dfinity/identity'; import type {JsonnableEd25519KeyIdentity} from '@dfinity/identity/lib/cjs/identity/ed25519'; import {nextArg} from '@junobuild/cli-tools'; import {bold, green, underline} from 'kleur'; @@ -10,7 +9,7 @@ import path, {dirname} from 'node:path'; import {fileURLToPath} from 'node:url'; import util from 'node:util'; import {saveCliConfig} from '../../configs/cli.config'; -import {authUrl, requestUrl} from '../../utils/auth.utils'; +import {authUrl, generateToken, requestUrl} from '../../utils/auth.utils'; import {openUrl} from '../../utils/open.utils'; import {getPort} from '../../utils/port.utils'; @@ -21,9 +20,7 @@ export const login = async (args?: string[]) => { const port = await getPort(); const nonce = randomBytes(16).toString('hex'); - const key = Ed25519KeyIdentity.generate(); - const principal = key.getPrincipal().toText(); - const token = key.toJSON(); + const {principal, token} = generateToken(); console.log(`\nšŸ”“ Your terminal will authenticate with admin access as: ${green(principal)}`); diff --git a/src/services/emulator/emulator.admin.services.ts b/src/services/emulator/emulator.admin.services.ts index 056a12cc..9b347f41 100644 --- a/src/services/emulator/emulator.admin.services.ts +++ b/src/services/emulator/emulator.admin.services.ts @@ -3,10 +3,12 @@ import type {CliEmulatorConfig} from '../../types/emulator'; export const dispatchRequest = async ({ config: emulatorConfig, - adminRequest + request, + timeout = 5000 }: { config: CliEmulatorConfig; - adminRequest: string; + request: string; + timeout?: number; }): Promise< | {result: 'ok'; response: Response} | {result: 'not_ok'; response: Response} @@ -20,8 +22,8 @@ export const dispatchRequest = async ({ const adminPort = config[emulatorType]?.ports?.admin ?? EMULATOR_SKYLAB.ports.admin; try { - const response = await fetch(`http://localhost:${adminPort}/admin/${adminRequest}`, { - signal: AbortSignal.timeout(5000) + const response = await fetch(`http://localhost:${adminPort}/${request}`, { + signal: AbortSignal.timeout(timeout) }); if (!response.ok) { diff --git a/src/services/functions/build/touch.services.ts b/src/services/functions/build/touch.services.ts index 096648e0..6add5c9d 100644 --- a/src/services/functions/build/touch.services.ts +++ b/src/services/functions/build/touch.services.ts @@ -42,7 +42,7 @@ const dispatchTouch = async ({filename}: {filename: string}) => { const {result} = await dispatchRequest({ config: parsedResult.config, - adminRequest: `touch?file=${encodeURIComponent(filename)}` + request: `admin/touch?file=${encodeURIComponent(filename)}` }); // We silence the error (result === error). Maybe the emulator is not running on purpose. diff --git a/src/utils/auth.utils.ts b/src/utils/auth.utils.ts index bc651091..88c82fd0 100644 --- a/src/utils/auth.utils.ts +++ b/src/utils/auth.utils.ts @@ -1,7 +1,18 @@ +import {Ed25519KeyIdentity} from '@dfinity/identity'; +import type {JsonnableEd25519KeyIdentity} from '@dfinity/identity/lib/cjs/identity/ed25519'; import {nonNullish} from '@dfinity/utils'; +import type {PrincipalText} from '@dfinity/zod-schemas'; import {REDIRECT_URL} from '../constants/constants'; import {ENV} from '../env'; +export const generateToken = (): {principal: PrincipalText; token: JsonnableEd25519KeyIdentity} => { + const key = Ed25519KeyIdentity.generate(); + const principal = key.getPrincipal().toText(); + const token = key.toJSON(); + + return {principal, token}; +}; + export const authUrl = ({ port, nonce, diff --git a/src/utils/satellite.utils.ts b/src/utils/satellite.utils.ts index ba8b259d..55db136a 100644 --- a/src/utils/satellite.utils.ts +++ b/src/utils/satellite.utils.ts @@ -1,5 +1,6 @@ import {assertNonNullish, isNullish} from '@dfinity/utils'; -import {type SatelliteConfig} from '@junobuild/config'; +import type {PrincipalText} from '@dfinity/zod-schemas'; +import type {SatelliteConfig} from '@junobuild/config'; import {red} from 'kleur'; import {actorParameters} from '../api/actor.api'; import {getCliOrbiters, getCliSatellites} from '../configs/cli.config'; @@ -29,10 +30,10 @@ export const assertConfigAndLoadSatelliteContext = async (): Promise<{ return {satellite, satelliteConfig}; }; -const satelliteParameters = async ({ +export const assertConfigAndReadSatelliteId = ({ satellite, env: {mode} -}: SatelliteConfigEnv): Promise => { +}: SatelliteConfigEnv): {satelliteId: PrincipalText} => { const {id, ids} = satellite; // Originally, the config used `satelliteId`, but we later migrated to `id` and `ids`. @@ -41,7 +42,7 @@ const satelliteParameters = async ({ const deprecatedSatelliteId = 'satelliteId' in satellite ? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (satellite as unknown as {satelliteId: string}).satelliteId + (satellite as unknown as {satelliteId: PrincipalText}).satelliteId : undefined; const satelliteId = ids?.[mode] ?? id ?? deprecatedSatelliteId; @@ -51,6 +52,14 @@ const satelliteParameters = async ({ process.exit(1); } + return {satelliteId}; +}; + +const satelliteParameters = async ( + params: SatelliteConfigEnv +): Promise => { + const {satelliteId} = assertConfigAndReadSatelliteId(params); + return { satelliteId, ...(await actorParameters())