From 8e838b90f99dea926fd6d070a7a06c710fe4ba03 Mon Sep 17 00:00:00 2001 From: Murat Date: Sat, 23 Mar 2024 03:46:33 +0300 Subject: [PATCH] feat(task): added shell task support fix #5 --- src/__tests__/unit/tasks/shellTask.spec.ts | 199 ++++++++++++++++++ src/__tests__/unit/utils/parseArgs.spec.ts | 16 ++ src/schema/integrate.schema.json | 62 ++++++ src/schema/upgrade.schema.json | 62 ++++++ src/tasks/shellTask.ts | 123 +++++++++++ src/types/mod.types.ts | 15 +- src/utils/parseArgs.ts | 24 +++ src/utils/taskManager.ts | 2 + .../guides/task-types/other-tasks/prompts.md | 28 +-- .../guides/task-types/other-tasks/shell.md | 68 ++++++ 10 files changed, 584 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/unit/tasks/shellTask.spec.ts create mode 100644 src/__tests__/unit/utils/parseArgs.spec.ts create mode 100644 src/tasks/shellTask.ts create mode 100644 src/utils/parseArgs.ts create mode 100644 website/docs/for-developers/guides/task-types/other-tasks/shell.md diff --git a/src/__tests__/unit/tasks/shellTask.spec.ts b/src/__tests__/unit/tasks/shellTask.spec.ts new file mode 100644 index 0000000..9d08f5f --- /dev/null +++ b/src/__tests__/unit/tasks/shellTask.spec.ts @@ -0,0 +1,199 @@ +require('../../mocks/mockAll'); +const mockSpawn = jest.spyOn(require('child_process'), 'spawn'); + +import { shellTask } from '../../../tasks/shellTask'; +import { ShellTaskType } from '../../../types/mod.types'; +import { variables } from '../../../variables'; +import { mockPrompter } from '../../mocks/mockPrompter'; + +describe('shellTask', () => { + it('should run command', async () => { + mockSpawn.mockImplementationOnce(() => ({ + on: (_event: string, cb: CallableFunction) => { + cb(0); + }, + stdout: { + on: (_event: string, cb: CallableFunction) => { + cb('stdout'); + }, + }, + stderr: { + on: (_event: string, cb: CallableFunction) => { + cb('stderr'); + }, + }, + })); + + const task: ShellTaskType = { + type: 'shell', + actions: [ + { + name: 'test', + command: 'test', + }, + ], + }; + + await shellTask({ + configPath: 'path/to/config', + task: task, + packageName: 'test-package', + }); + + expect(mockSpawn).toHaveBeenCalled(); + expect(variables.get('test.output')).toBe('stdoutstderr'); + + mockSpawn.mockReset(); + }); + it('should run command with args', async () => { + mockSpawn.mockImplementationOnce(() => ({ + on: (_event: string, cb: CallableFunction) => { + cb(0); + }, + stdout: { + on: (_event: string, cb: CallableFunction) => { + cb('stdout'); + }, + }, + stderr: { + on: (_event: string, cb: CallableFunction) => { + cb('stderr'); + }, + }, + })); + + const task: ShellTaskType = { + type: 'shell', + actions: [ + { + name: 'test', + command: 'test', + args: ['arg1', 'arg2 arg3'], + }, + ], + }; + + await shellTask({ + configPath: 'path/to/config', + task: task, + packageName: 'test-package', + }); + + expect(mockSpawn).toHaveBeenCalledWith('test', ['arg1', 'arg2 arg3']); + + mockSpawn.mockReset(); + }); + it('should handle unexpected error', async () => { + mockSpawn.mockImplementationOnce(() => { + throw new Error('unexpected error'); + }); + + const task: ShellTaskType = { + type: 'shell', + actions: [ + { + name: 'test', + command: 'test', + }, + ], + }; + + await expect( + shellTask({ + configPath: 'path/to/config', + task: task, + packageName: 'test-package', + }) + ).rejects.toThrowError('unexpected error'); + + expect(variables.get('test')).toEqual('error'); + + mockSpawn.mockReset(); + }); + it('should handle non zero exit code', async () => { + mockSpawn.mockImplementationOnce(() => ({ + on: (_event: string, cb: CallableFunction) => { + cb(1); + }, + stdout: { + on: (_event: string, cb: CallableFunction) => { + cb('stdout'); + }, + }, + stderr: { + on: (_event: string, cb: CallableFunction) => { + cb('stderr'); + }, + }, + })); + + const task: ShellTaskType = { + type: 'shell', + actions: [ + { + name: 'test', + command: 'test', + }, + ], + }; + + await expect( + shellTask({ + configPath: 'path/to/config', + task: task, + packageName: 'test-package', + }) + ).rejects.toThrowError('non zero exit code'); + + expect(variables.get('test')).toEqual('error'); + + mockSpawn.mockReset(); + }); + it('should skip when condition does not meet', async () => { + const task: ShellTaskType = { + type: 'shell', + actions: [ + { + when: { random: false }, + name: 'test', + command: 'test', + }, + ], + }; + + await shellTask({ + configPath: 'path/to/config', + task: task, + packageName: 'test-package', + }); + + expect(mockSpawn).not.toHaveBeenCalled(); + + mockSpawn.mockReset(); + }); + it('should skip when execution not allowed', async () => { + mockPrompter.confirm.mockClear(); + mockPrompter.confirm.mockReturnValueOnce(false); + + const task: ShellTaskType = { + type: 'shell', + actions: [ + { + name: 'test', + command: 'test', + }, + ], + }; + + await shellTask({ + configPath: 'path/to/config', + task: task, + packageName: 'test-package', + }); + + expect(mockSpawn).not.toHaveBeenCalled(); + + mockSpawn.mockReset(); + mockPrompter.confirm.mockReset(); + }); +}); diff --git a/src/__tests__/unit/utils/parseArgs.spec.ts b/src/__tests__/unit/utils/parseArgs.spec.ts new file mode 100644 index 0000000..75b925a --- /dev/null +++ b/src/__tests__/unit/utils/parseArgs.spec.ts @@ -0,0 +1,16 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ + +require('../../mocks/mockAll'); + +import { parseArgs } from '../../../utils/parseArgs'; + +describe('parseArgs', () => { + it('should parse args correctly', () => { + const args = parseArgs('arg1 "arg2 arg3" arg4'); + expect(args).toEqual(['arg1', 'arg2 arg3', 'arg4']); + }); + it('should parse escaped args correctly', () => { + const args = parseArgs('arg1 "arg\\"2 a\\"rg3"'); + expect(args).toEqual(['arg1', 'arg"2 a"rg3']); + }); +}); diff --git a/src/schema/integrate.schema.json b/src/schema/integrate.schema.json index 39f5208..a691de0 100644 --- a/src/schema/integrate.schema.json +++ b/src/schema/integrate.schema.json @@ -571,6 +571,9 @@ { "$ref": "#/definitions/FsTaskType" }, + { + "$ref": "#/definitions/ShellTaskType" + }, { "$ref": "#/definitions/PromptTaskType" } @@ -1074,6 +1077,65 @@ ], "type": "object" }, + "ShellActionType": { + "additionalProperties": false, + "properties": { + "args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "when": { + "$ref": "#/definitions/AnyObject" + } + }, + "required": [ + "command" + ], + "type": "object" + }, + "ShellTaskType": { + "additionalProperties": false, + "properties": { + "actions": { + "items": { + "$ref": "#/definitions/ShellActionType" + }, + "type": "array" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string" + }, + "postInfo": { + "$ref": "#/definitions/TextOrTitleMessage" + }, + "preInfo": { + "$ref": "#/definitions/TextOrTitleMessage" + }, + "type": { + "const": "shell", + "type": "string" + }, + "when": { + "$ref": "#/definitions/AnyObject" + } + }, + "required": [ + "actions", + "type" + ], + "type": "object" + }, "StringsXmlTaskType": { "additionalProperties": false, "properties": { diff --git a/src/schema/upgrade.schema.json b/src/schema/upgrade.schema.json index ec8ef1d..6abdb50 100644 --- a/src/schema/upgrade.schema.json +++ b/src/schema/upgrade.schema.json @@ -571,6 +571,9 @@ { "$ref": "#/definitions/FsTaskType" }, + { + "$ref": "#/definitions/ShellTaskType" + }, { "$ref": "#/definitions/PromptTaskType" } @@ -1074,6 +1077,65 @@ ], "type": "object" }, + "ShellActionType": { + "additionalProperties": false, + "properties": { + "args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "when": { + "$ref": "#/definitions/AnyObject" + } + }, + "required": [ + "command" + ], + "type": "object" + }, + "ShellTaskType": { + "additionalProperties": false, + "properties": { + "actions": { + "items": { + "$ref": "#/definitions/ShellActionType" + }, + "type": "array" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string" + }, + "postInfo": { + "$ref": "#/definitions/TextOrTitleMessage" + }, + "preInfo": { + "$ref": "#/definitions/TextOrTitleMessage" + }, + "type": { + "const": "shell", + "type": "string" + }, + "when": { + "$ref": "#/definitions/AnyObject" + } + }, + "required": [ + "actions", + "type" + ], + "type": "object" + }, "StringsXmlTaskType": { "additionalProperties": false, "properties": { diff --git a/src/tasks/shellTask.ts b/src/tasks/shellTask.ts new file mode 100644 index 0000000..e2def2c --- /dev/null +++ b/src/tasks/shellTask.ts @@ -0,0 +1,123 @@ +// noinspection ExceptionCaughtLocallyJS + +import { spawn } from 'child_process'; +import color from 'picocolors'; +import { Constants } from '../constants'; +import { + confirm, + logMessageGray, + startSpinner, + stopSpinner, +} from '../prompter'; +import { ShellTaskType } from '../types/mod.types'; +import { getErrMessage } from '../utils/getErrMessage'; +import { parseArgs } from '../utils/parseArgs'; +import { satisfies } from '../utils/satisfies'; +import { setState } from '../utils/setState'; +import { variables } from '../variables'; + +export async function shellTask(args: { + configPath: string; + packageName: string; + task: ShellTaskType; +}): Promise { + const { task, packageName } = args; + + for (const action of task.actions) { + if (action.when && !satisfies(variables.getStore(), action.when)) { + setState(action.name, { + state: 'skipped', + reason: 'when', + error: false, + }); + continue; + } + + setState(action.name, { + state: 'progress', + error: false, + }); + try { + let command: string, args: string[]; + if (action.args) { + command = action.command; + args = action.args; + } else { + const cmdWithArgs = parseArgs(action.command); + command = cmdWithArgs[0]; + args = cmdWithArgs.slice(1); + } + + if (packageName !== Constants.UPGRADE_CONFIG_FILE_NAME) { + const isAllowed = await confirm( + `requesting permission to run ${color.yellow(command + ' ' + args.join(' '))}`, + { + positive: 'allow', + negative: 'skip', + } + ); + if (!isAllowed) { + setState(action.name, { + state: 'skipped', + reason: 'user denied', + error: false, + }); + logMessageGray( + `skipped running ${color.yellow(command + ' ' + args.join(' '))}` + ); + continue; + } + } + let output = ''; + startSpinner(`running ${color.yellow(command + ' ' + args.join(' '))}`); + let exitCode: number | undefined = undefined; + try { + exitCode = await new Promise((resolve, reject) => { + try { + const child = spawn(command, args); + child.stdout.on('data', chunk => { + output += chunk; + }); + child.stderr.on('data', chunk => { + output += chunk; + }); + // child.stdout.pipe(process.stdout); + // child.stderr.pipe(process.stderr); + child.on('close', code => { + resolve(code); + }); + } catch (e) { + reject(e); + } + }); + } finally { + if (action.name) variables.set(`${action.name}.output`, output); + if (exitCode == null) { + // throwing error + stopSpinner( + `run failed using ${color.yellow(command + ' ' + args.join(' '))}` + ); + } + } + if (exitCode != 0) { + stopSpinner( + `run failed using ${color.yellow(command + ' ' + args.join(' '))}` + ); + throw new Error(`process exit with non zero exit code (${exitCode})`); + } else { + stopSpinner(`run ${color.yellow(command + ' ' + args.join(' '))}`); + } + } catch (e) { + setState(action.name, { + state: 'error', + reason: getErrMessage(e), + error: true, + }); + throw e; + } + } +} + +export const runTask = shellTask; + +export const summary = 'shell execution'; diff --git a/src/types/mod.types.ts b/src/types/mod.types.ts index 60a6f8c..fbc2ad7 100644 --- a/src/types/mod.types.ts +++ b/src/types/mod.types.ts @@ -361,6 +361,18 @@ export type FsModifierType = ActionBase & { destination: string; }; +// shell task + +export type ShellTaskType = ModTaskBase & + ActionsType & { + type: 'shell'; + }; + +export type ShellActionType = ActionBase & { + command: string; + args?: string[]; +}; + // prompt task export type PromptTaskType = ModTaskBase & @@ -393,7 +405,8 @@ export type ModTask = | GitignoreTaskType | FsTaskType | JsonTaskType - | PromptTaskType; + | PromptTaskType + | ShellTaskType; export type ValidationType = { regex: string; flags?: string; message: string }; export type TextPrompt = Omit & { diff --git a/src/utils/parseArgs.ts b/src/utils/parseArgs.ts new file mode 100644 index 0000000..efcb9ca --- /dev/null +++ b/src/utils/parseArgs.ts @@ -0,0 +1,24 @@ +export function parseArgs(argString: string) { + const parsedArgs = []; + if (argString) { + let arg = '', + quotes = false; + for (let i = 0; i < argString.length; i++) { + if (argString[i] == '"' && (i == 0 || argString[i - 1] != '\\')) { + if (quotes) { + if (arg) parsedArgs.push(arg); + arg = ''; + quotes = false; + } else quotes = true; + } else if (argString[i] == ' ' && !quotes) { + if (arg) parsedArgs.push(arg); + arg = ''; + } else if (argString[i] == '\\' && (i == 0 || argString[i - 1] != '\\')) { + // noinspection UnnecessaryContinueJS + continue; + } else arg += argString[i]; + } + if (arg) parsedArgs.push(arg); + } + return parsedArgs; +} diff --git a/src/utils/taskManager.ts b/src/utils/taskManager.ts index 066585d..c8beb0e 100644 --- a/src/utils/taskManager.ts +++ b/src/utils/taskManager.ts @@ -13,6 +13,7 @@ import * as notification_service from '../tasks/notificationServiceTask'; import * as notification_view_controller from '../tasks/notificationViewControllerTask'; import * as main_application from '../tasks/mainApplicationTask'; import * as settings_gradle from '../tasks/settingsGradleTask'; +import * as shell from '../tasks/shellTask'; import { ModTask } from '../types/mod.types'; const task: Record = { @@ -31,6 +32,7 @@ const task: Record = { main_application, settings_gradle, strings_xml, + shell, }; const systemTaskTypes = Object.entries(task) diff --git a/website/docs/for-developers/guides/task-types/other-tasks/prompts.md b/website/docs/for-developers/guides/task-types/other-tasks/prompts.md index ce83972..edcb936 100644 --- a/website/docs/for-developers/guides/task-types/other-tasks/prompts.md +++ b/website/docs/for-developers/guides/task-types/other-tasks/prompts.md @@ -9,23 +9,23 @@ The `prompt` task type allows you to gather user input during the integration pr ## Task Properties -| Property | Type | Description | -|:---------|:------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------| -| type | "prompt", required | Specifies the task type, which should be set to "prompt" for this task. | -| name | string | An optional name for the task. If provided, the task state will be saved as a variable. Visit [Task and Action States](../../states) page to learn more. | -| label | string | An optional label or description for the task. | -| when | object | Visit [Conditional Tasks and Actions](../../when) page to learn how to execute task conditionally. | -| actions | Array\<[Action](#action-properties)\>, required | An array of action items that define the modifications to be made in the file. | +| Property | Type | Description | +|:---------|:------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| +| type | "prompt", required | Specifies the task type, which should be set to "prompt" for this task. | +| name | string | An optional name for the task. If provided, the task state will be saved as a variable. Visit [Task and Action States](../../states) page to learn more. | +| label | string | An optional label or description for the task. | +| when | object | Visit [Conditional Tasks and Actions](../../when) page to learn how to execute task conditionally. | +| actions | Array\<[Action](#action-properties)\>, required | An array of action items that define the modifications to be made in the file. | ## Action Properties -| Property | Type | Description | -|:---------|:-----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------| -| name | string | An optional name for the task. If provided, the task state will be saved as a variable. Visit [Task and Action States](../../states) page to learn more. | -| when | object | Visit [Conditional Tasks and Actions](../../when) page to learn how to execute action conditionally. | -| text | "text" or "boolean" or "multiselect", required | The text displayed to prompt the user. | -| type | string, required | Specifies the type of prompt to display. | -| message | string | A string that serves as the user prompt message when collecting input. If provided, this message will replace the default message. | +| Property | Type | Description | +|:---------|:-----------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| +| name | string | An optional name for the task. If provided, the task state will be saved as a variable. Visit [Task and Action States](../../states) page to learn more. | +| when | object | Visit [Conditional Tasks and Actions](../../when) page to learn how to execute action conditionally. | +| text | "text" or "boolean" or "multiselect", required | The text displayed to prompt the user. | +| type | string, required | Specifies the type of prompt to display. | +| message | string | A string that serves as the user prompt message when collecting input. If provided, this message will replace the default message. | #### _The action item can take these properties based on which action you want to execute._ diff --git a/website/docs/for-developers/guides/task-types/other-tasks/shell.md b/website/docs/for-developers/guides/task-types/other-tasks/shell.md new file mode 100644 index 0000000..1ffed06 --- /dev/null +++ b/website/docs/for-developers/guides/task-types/other-tasks/shell.md @@ -0,0 +1,68 @@ +--- +sidebar_position: 4 +title: Shell Commands +--- +# Shell Task Configuration (`shell`) +_Run shell commands_ + +The `shell` task type allows you to run shell commands. + +## Task Properties + +| Property | Type | Description | +|:---------|:------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| type | "shell", required | Specifies the task type, which should be set to "shell" for this task. | +| name | string | An optional name for the task. If provided, the task state will be saved as a variable. Visit [Task and Action States](../../states) page to learn more. | +| label | string | An optional label or description for the task. | +| when | object | Visit [Conditional Tasks and Actions](../../when) page to learn how to execute task conditionally. | +| actions | Array\<[Action](#action-properties)\>, required | An array of action items that define the modifications to be made in the file. | + +## Action Properties + +| Property | Type | Description | +|:---------|:-----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| name | string | An optional name for the task. If provided, the task state will be saved as a variable. Visit [Task and Action States](../../states) page to learn more. | +| when | object | Visit [Conditional Tasks and Actions](../../when) page to learn how to execute action conditionally. | +| command | string, required | The process name to spawn. Command can have simple args, parser supports quotes. For more complex args, use `args` property. | +| args | Array\ | Array of args to be past directly to process. | + + +## Example + +Here's an example of how to use shell task in a configuration file: + +```yaml +tasks: + - type: shell + label: Embedding assets + actions: + - command: npx react-native-asset + - command: some-command --some-flag "some literal args" + - command: other-command + args: + - --some-flag + - "some \"complex\" args" +``` +:::tip +Specify `name` field for this action to expose the `name.output` variable which will hold the output of the process. + +#### Example: +```yaml + # run command + - command: npx react-native-asset + name: cmd_asset # Give it a name + + # you can run another command if previous was success and output contains some value + - when: + cmd_asset: done + cmd_asset.output: # use the name here + $regex: somevalue + command: some-other-command + ``` +::: + +:::warning +`shell` commands that are used in package integrations will always ask permission from user before execution. User may choose to skip the execution, so don't rely on it too much. + +It does not ask permission when used in [upgrade.yml](../../../../upgrade/configuration). +:::