diff --git a/.vscode/icloud-photos-sync.cspell b/.vscode/icloud-photos-sync.cspell index 3e68623d..a000fbde 100644 --- a/.vscode/icloud-photos-sync.cspell +++ b/.vscode/icloud-photos-sync.cspell @@ -94,6 +94,7 @@ Symbolication templating testparam testpassword +thundernetworkrad timepicker tplrgn truevision diff --git a/.vscode/launch.json b/.vscode/launch.json index 18829f12..c73562a3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -65,7 +65,7 @@ "--runInBand", "--config", "jest.config.json", //"--detectOpenHandles", - "test/unit/icloud.test.ts" + "test/unit/app.test.ts" ], "env": { "NODE_NO_WARNINGS": "1" diff --git a/app/package-lock.json b/app/package-lock.json index 24529274..69a6c787 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -15,6 +15,7 @@ ], "dependencies": { "@backtrace-labs/node": "^0.0.1", + "@thundernetworkrad/readline-sync": "^2023.3.13-1", "ajv": "^8.12.0", "axios": "^1.2.2", "axios-har-tracker": "^0.5.1", @@ -1892,6 +1893,14 @@ "node": ">= 6" } }, + "node_modules/@thundernetworkrad/readline-sync": { + "version": "2023.3.13-1", + "resolved": "https://registry.npmjs.org/@thundernetworkrad/readline-sync/-/readline-sync-2023.3.13-1.tgz", + "integrity": "sha512-x3I1GRPy5jHSk4BdqT0I8dKul9W7qy2jT3X3akAMzVIpX/l2m5VWHIsn4vfYl/9G5S54WeN1LOmtBA95xabt1w==", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", diff --git a/app/package.json b/app/package.json index c82e51be..fd2f44ca 100644 --- a/app/package.json +++ b/app/package.json @@ -85,6 +85,7 @@ }, "dependencies": { "@backtrace-labs/node": "^0.0.1", + "@thundernetworkrad/readline-sync": "^2023.3.13-1", "ajv": "^8.12.0", "axios": "^1.2.2", "axios-har-tracker": "^0.5.1", diff --git a/app/src/app/factory.ts b/app/src/app/factory.ts index 558f6076..0cc70234 100644 --- a/app/src/app/factory.ts +++ b/app/src/app/factory.ts @@ -3,6 +3,7 @@ import Cron from "croner"; import {TokenApp, SyncApp, ArchiveApp, iCPSApp, DaemonApp} from "./icloud-app.js"; import {Resources} from "../lib/resources/main.js"; import {LogLevel} from "./event/log.js"; +import {question, BasicOptions} from '@thundernetworkrad/readline-sync'; /** * This function can be used as a commander argParser. It will try to parse the value as a positive integer and throw an invalid argument error in case it fails @@ -56,6 +57,21 @@ function commanderParseCron(value: string, _dummyPrevious?: unknown): string { } } +/** + * This function reads a value that can be optionally empty and asks for user input to provide the actual value + * @param questionFct - Function to query for input - parameterized for testability + * @param value - The string literal, read from the CLI + * @param _dummyPrevious - Conforming to the interface - unused + * @returns The original string, or user provided input, in case the input was undefined or empty + */ +function commanderParseOptionalAndReadFromStdin(questionFct: (query?: any, options?: BasicOptions) => string, value: string, _dummyPrevious?: unknown): string { + if (value && value.length > 0) { + return value; + } + + return questionFct(`Please enter your iCloud password: `, {hideEchoBack: true}); +} + /** * This function can be used as a commander argParser. It will try to parse the value as an interval with the format \/\. * @param value - The string literal, read from the CLI @@ -111,9 +127,10 @@ export type iCPSAppOptions = { /** * Creates the argument parser for the CLI and environment variables * @param callback - A callback function that will be called with the created app, based on the provided options. + * @param questionFct - The function to query for input from the CLI - parameterized for testing purposes * @returns The commander command object, awaiting .parse() to be called */ -export function argParser(callback: (res: iCPSApp) => void): Command { +export function argParser(callback: (res: iCPSApp) => void, questionFct: (query?: any, options?: BasicOptions) => string): Command { // Overwriting commander\`s _exit function, because exitOverwrite will still call process.exit (Command.prototype as any)._exit = (exitCode: number, code: string, message: string) => { throw new CommanderError(exitCode, code, message); // Function needs to return \`never\`, otherwise errors will be ignored @@ -126,9 +143,11 @@ export function argParser(callback: (res: iCPSApp) => void): Command { .addOption(new Option(`-u, --username `, `AppleID username.`) .env(`APPLE_ID_USER`) .makeOptionMandatory(true)) - .addOption(new Option(`-p, --password `, `AppleID password.`) + .addOption(new Option(`-p, --password [string]`, `AppleID password. Omitting the option or providing an empty string will result in the CLI to ask for user input before startup.`) + .preset('') + .makeOptionMandatory() .env(`APPLE_ID_PWD`) - .makeOptionMandatory(true)) + .argParser(commanderParseOptionalAndReadFromStdin.bind(this, questionFct))) .addOption(new Option(`-T, --trust-token `, `The trust token for authentication. If not provided, the trust token is read from the \`.icloud-photos-sync\` resource file in data dir. If no stored trust token could be loaded, a new trust token will be acquired (requiring the input of an MFA code).`) .env(`TRUST_TOKEN`)) .addOption(new Option(`-d, --data-dir `, `Directory to store local copy of library.`) @@ -215,7 +234,7 @@ export function argParser(callback: (res: iCPSApp) => void): Command { .description(`Fetches the remote state and persist it to the local disk once.`); program.command(`archive`) - .action((archivePath, _, command) => { + .action(async (archivePath, _, command) => { Resources.setup(command.parent.opts()); callback(new ArchiveApp(archivePath)); }) @@ -228,14 +247,22 @@ export function argParser(callback: (res: iCPSApp) => void): Command { /** * This function will parse the provided string array and environment variables and return the correct application object. * @param argv - The argument vector to be parsed + * @param questionFct - The function to query for input from the CLI - parameterized for testing purposes, readline-sync used per default * @returns - A promise that resolves to the correct application object. Once the promise resolves, the global resource singleton will also be available. If the program is not able to parse the options, or required options are missing, an error message is printed to stderr and the promise rejects with a CommanderError. */ -export async function appFactory(argv: string[]): Promise { +export async function appFactory(argv: string[], questionFct: (query?: any, options?: BasicOptions) => string = question): Promise { return new Promise((resolve, reject) => { try { argParser((res: iCPSApp) => { resolve(res); - }).parse(argv); + }, questionFct).parse( + argv.indexOf('-p') >=0 + ? argv + : [ + ...argv, + '-p' // making sure -p is present to trigger commander preset + ] + ); } catch (err) { reject(err); } diff --git a/app/test/_helpers/app-factory.helper.ts b/app/test/_helpers/app-factory.helper.ts index 8b92cc4a..9f4d7667 100644 --- a/app/test/_helpers/app-factory.helper.ts +++ b/app/test/_helpers/app-factory.helper.ts @@ -5,18 +5,8 @@ export const rejectOptions = [ `/home/icloud-photos-sync/main.js`, `token`, ], - _desc: `Missing username & password`, + _desc: `Missing username`, expected: `error: required option '-u, --username ' not specified`, - }, { - options: [ - `/usr/bin/node`, - `/home/icloud-photos-sync/main.js`, - `-u`, - `test@icloud.com`, - `token`, - ], - _desc: `Missing password`, - expected: `error: required option '-p, --password ' not specified`, }, { options: [ `/usr/bin/node`, diff --git a/app/test/unit/app.test.ts b/app/test/unit/app.test.ts index c8950e6f..4bc91490 100644 --- a/app/test/unit/app.test.ts +++ b/app/test/unit/app.test.ts @@ -10,6 +10,7 @@ import {prepareResources, spyOnEvent} from '../_helpers/_general'; import path from 'path'; import {iCPSEventApp, iCPSEventCloud, iCPSEventRuntimeError} from '../../src/lib/resources/events-types'; import {Resources} from '../../src/lib/resources/main'; +import { BasicOptions } from '@thundernetworkrad/readline-sync' beforeEach(() => { mockfs(); @@ -61,6 +62,38 @@ describe(`App Factory`, () => { }); }); + // Currently don't know how to test this... + test.each([ + { + desc: 'No parameter', + parameter: [] + },{ + desc: 'Empty parameter', + parameter: [ + `-p`, + ``, + ] + } + ])(`Asking user to provide password: $desc`, async ({parameter}) => { + const setupSpy = jest.spyOn(Resources, `setup`); + const questionFct = jest.fn<(query?: any, options?: BasicOptions) => string>().mockReturnValue('testPass') + const app = await appFactory( + [ + `/usr/bin/node`, + `/home/icloud-photos-sync/main.js`, + `-u`, + `test@icloud.com`, + ...parameter, + `token`, + ], + questionFct + ); + + expect(app).toBeInstanceOf(TokenApp); + expect(setupSpy).toHaveBeenCalledWith(Config.defaultConfig); + expect(questionFct).toHaveBeenCalled(); + }); + test(`Create Token App`, async () => { const tokenApp = await appFactory(validOptions.token) as TokenApp;