diff --git a/.vscodeignore b/.vscodeignore index d255964..d28a21b 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -3,6 +3,7 @@ out/** node_modules/** src/** +images/** .gitignore .yarnrc webpack.config.js diff --git a/README.md b/README.md index e91f2fa..88a0733 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,14 @@ [![Version](https://img.shields.io/visual-studio-marketplace/v/SanjulaGanepola.github-local-actions)](https://marketplace.visualstudio.com/items?itemName=SanjulaGanepola.github-local-actions) [![Installs](https://img.shields.io/visual-studio-marketplace/i/SanjulaGanepola.github-local-actions)](https://marketplace.visualstudio.com/items?itemName=SanjulaGanepola.github-local-actions) -Run your GitHub Actions locally with the power of the [GitHub Local Actions](https://marketplace.visualstudio.com/items?itemName=SanjulaGanepola.github-local-actions) VSCode extension! Say goodbye to the hassle of committing and pushing changes every time you need to test a workflow. This extension lets you quickly and efficiently run your workflows locally, leveraging the [nektos/act](https://github.com/nektos/act) CLI tool. Enjoy a seamless experience with an interface designed to feel as familiar as the official [GitHub Actions](https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-github-actions) extension. +Run your GitHub Actions locally with the power of the [GitHub Local Actions](https://marketplace.visualstudio.com/items?itemName=SanjulaGanepola.github-local-actions) VS Code extension! Say goodbye to the hassle of committing and pushing changes every time you need to test a workflow. This extension lets you quickly and efficiently run your workflows locally, leveraging the [nektos/act](https://github.com/nektos/act) CLI tool. Enjoy a seamless experience with an interface designed to feel as familiar as the official [GitHub Actions](https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-github-actions) extension. * 🚀 **Run Workflows/Jobs**: Run entire workflows or specific jobs locally without leaving your editor. * ⚡ **Trigger Events**: Trigger standard [GitHub events](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows) to run multiple workflows. * 📖 **View Workflow Run History**: Track and investigate past workflow logs. * ⚙️ **Manage Workflow Settings**: Define secrets, variables, inputs, and runners to be used when executing workflows. -![GitHub Local Actions](./images/github-local-actions.gif) +![GitHub Local Actions](https://raw.githubusercontent.com/SanjulaGanepola/github-local-actions/main/images/github-local-actions.gif) ## Components @@ -20,7 +20,7 @@ The `Components` view is where you can manage the following components which mus 1. [nektos/act](https://github.com/nektos/act): Act is a CLI tool responsible for executing your workflows locally. It can be installed via several software package managers or using a pre-built artifact. - ![nektos/act Installation](./images/components-view.png) + ![nektos/act Installation](https://raw.githubusercontent.com/SanjulaGanepola/github-local-actions/main/images/components-view.png) 2. [Docker Engine](https://docs.docker.com/engine): Docker Engine is required for act to function as it uses Docker to handle workflow execution. The process includes: @@ -39,13 +39,13 @@ The `Workflows` view is where you can manage and run workflows locally. You have 3. **Run Job**: Run a specific job in a workflow. 4. **Run Event**: Run multiple workflows by triggering a [GitHub event](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows). -![Workflows View](./images/workflows-view.png) +![Workflows View](https://raw.githubusercontent.com/SanjulaGanepola/github-local-actions/main/images/workflows-view.png) ## History The `History` view is where you can manage workflows currently being executed as well as review logs from previous workflow runs. -![History View](./images/history-view.png) +![History View](https://raw.githubusercontent.com/SanjulaGanepola/github-local-actions/main/images/history-view.png) ## Settings @@ -53,7 +53,7 @@ The `Settings` view is where you can set [secrets](https://nektosact.com/usage/i > 🚨 Checkout the [nektos/act](https://nektosact.com) documentation for more details on how these various settings are handled. In particular, refer to how secrets can be provided to workflows interactively, as environment variables, or from a file. -![Settings View](./images/settings-view.png) +![Settings View](https://raw.githubusercontent.com/SanjulaGanepola/github-local-actions/main/images/settings-view.png) ## Bugs and Feature Requests diff --git a/package-lock.json b/package-lock.json index f3665da..553747c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "github-local-actions", - "version": "1.1.0", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-local-actions", - "version": "1.1.0", + "version": "1.1.2", "license": "Apache-2.0", "dependencies": { "child_process": "^1.0.2", diff --git a/package.json b/package.json index 9d7a932..8e48db5 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "publisher": "SanjulaGanepola", "license": "Apache-2.0", - "version": "1.1.0", + "version": "1.1.2", "repository": { "url": "https://github.com/SanjulaGanepola/github-local-actions" }, @@ -156,6 +156,12 @@ "title": "Start", "icon": "$(debug-start)" }, + { + "category": "GitHub Local Actions", + "command": "githubLocalActions.fixPermissions", + "title": "Fix Permissions", + "icon": "$(unlock)" + }, { "category": "GitHub Local Actions", "command": "githubLocalActions.runAllWorkflows", @@ -366,6 +372,10 @@ "command": "githubLocalActions.startComponent", "when": "never" }, + { + "command": "githubLocalActions.fixPermissions", + "when": "never" + }, { "command": "githubLocalActions.runAllWorkflows", "when": "never" @@ -555,6 +565,11 @@ "when": "view == components && viewItem =~ /^githubLocalActions.component_Not Running.*/", "group": "inline@1" }, + { + "command": "githubLocalActions.fixPermissions", + "when": "view == components && viewItem =~ /^githubLocalActions.component_Invalid Permissions.*/", + "group": "inline@1" + }, { "command": "githubLocalActions.runAllWorkflows", "when": "view == workflows && viewItem =~ /^githubLocalActions.workspaceFolderWorkflows.*/ && workspaceFolderCount > 1", @@ -685,7 +700,12 @@ "configuration": { "title": "GitHub Local Actions", "properties": { - "githubLocalActions.dockerEngine.dockerDesktopPath": { + "githubLocalActions.actCommand": { + "markdownDescription": "The base `nektos/act` command to be called. By default, this will be `act` (requires the binary to be on your `PATH`). If the binary is not on your `PATH`, the command should be fully qualified. If `act` is installed as a GitHub CLI extension, this command should be set to `gh act`.", + "type": "string", + "default": "act" + }, + "githubLocalActions.dockerDesktopPath": { "markdownDescription": "The path to your Docker Desktop executable (used for Windows and MacOS). To start Docker Engine from the `Components` view, this application will be launched. Refer to the default path based on OS:\n\n* **Windows**: `C:/Program Files/Docker/Docker/Docker Desktop.exe`\n\n* **MacOS**: `/Applications/Docker.app`", "type": "string", "default": "" diff --git a/src/act.ts b/src/act.ts index b45e529..0e7364b 100644 --- a/src/act.ts +++ b/src/act.ts @@ -1,7 +1,10 @@ +import * as childProcess from "child_process"; +import * as fs from "fs/promises"; import * as path from "path"; import sanitize from "sanitize-filename"; -import { ExtensionContext, ShellExecution, TaskGroup, TaskPanelKind, TaskRevealKind, tasks, TaskScope, Uri, window, workspace, WorkspaceFolder } from "vscode"; +import { commands, CustomExecution, env, EventEmitter, ExtensionContext, Pseudoterminal, ShellExecution, TaskDefinition, TaskGroup, TaskPanelKind, TaskRevealKind, tasks, TaskScope, TerminalDimensions, Uri, window, workspace, WorkspaceFolder } from "vscode"; import { ComponentsManager } from "./componentsManager"; +import { ConfigurationManager, Platform, Section } from "./configurationManager"; import { componentsTreeDataProvider, historyTreeDataProvider } from './extension'; import { HistoryManager, HistoryStatus } from './historyManager'; import { SecretManager } from "./secretManager"; @@ -70,7 +73,8 @@ export interface CommandArgs { } export class Act { - private static base: string = 'act'; + static defaultActCommand: string = 'act'; + static githubCliActCommand: string = 'gh act'; context: ExtensionContext; storageManager: StorageManager; secretManager: SecretManager; @@ -80,6 +84,8 @@ export class Act { settingsManager: SettingsManager; installationCommands: { [packageManager: string]: string }; prebuiltExecutables: { [architecture: string]: string }; + refreshInterval: NodeJS.Timeout | undefined; + runningTaskCount: number; constructor(context: ExtensionContext) { this.context = context; @@ -89,6 +95,7 @@ export class Act { this.workflowsManager = new WorkflowsManager(); this.historyManager = new HistoryManager(this.storageManager); this.settingsManager = new SettingsManager(this.storageManager, this.secretManager); + this.runningTaskCount = 0; switch (process.platform) { case 'win32': @@ -96,7 +103,7 @@ export class Act { 'Chocolatey': 'choco install act-cli', 'Winget': 'winget install nektos.act', 'Scoop': 'scoop install act', - 'GitHub CLI': 'gh extension install https://github.com/nektos/gh-act' + 'GitHub CLI': '(gh auth status || gh auth login) && gh extension install https://github.com/nektos/gh-act' }; this.prebuiltExecutables = { @@ -111,7 +118,7 @@ export class Act { 'Homebrew': 'brew install act', 'Nix': 'nix run nixpkgs#act', 'MacPorts': 'sudo port install act', - 'GitHub CLI': 'gh extension install https://github.com/nektos/gh-act' + 'GitHub CLI': '(gh auth status || gh auth login) && gh extension install https://github.com/nektos/gh-act' }; this.prebuiltExecutables = { @@ -123,9 +130,10 @@ export class Act { this.installationCommands = { 'Homebrew': 'brew install act', 'Nix': 'nix run nixpkgs#act', + 'Arch': 'pacman -Syu act', 'AUR': 'yay -Syu act', 'COPR': 'dnf copr enable goncalossilva/act && dnf install act-cli', - 'GitHub CLI': 'gh extension install https://github.com/nektos/gh-act' + 'GitHub CLI': '(gh auth status || gh auth login) && gh extension install https://github.com/nektos/gh-act' }; this.prebuiltExecutables = { @@ -142,80 +150,57 @@ export class Act { } // Setup automatic history view refreshing - let refreshInterval: NodeJS.Timeout | undefined; tasks.onDidStartTask(e => { const taskDefinition = e.execution.task.definition; - if (taskDefinition.type === 'GitHub Local Actions' && !refreshInterval) { - refreshInterval = setInterval(() => { - historyTreeDataProvider.refresh(); - }, 1000); + if (taskDefinition.type === 'GitHub Local Actions') { + this.runningTaskCount++; + + if (!this.refreshInterval && this.runningTaskCount >= 0) { + this.refreshInterval = setInterval(() => { + historyTreeDataProvider.refresh(); + }, 1000); + } } }); tasks.onDidEndTask(e => { const taskDefinition = e.execution.task.definition; if (taskDefinition.type === 'GitHub Local Actions') { - if (refreshInterval) { - clearInterval(refreshInterval); - refreshInterval = undefined; + this.runningTaskCount--; + + if (this.refreshInterval && this.runningTaskCount == 0) { + clearInterval(this.refreshInterval); + this.refreshInterval = undefined; } } }); // Refresh components view after installation - tasks.onDidEndTask(e => { + tasks.onDidEndTaskProcess(async e => { const taskDefinition = e.execution.task.definition; - if (taskDefinition.type === 'nektos/act installation') { + if (taskDefinition.type === 'nektos/act installation' && e.exitCode === 0) { + this.updateActCommand(taskDefinition.ghCliInstall ? Act.githubCliActCommand : Act.defaultActCommand); componentsTreeDataProvider.refresh(); } }); + } - tasks.onDidStartTaskProcess(e => { - const taskDefinition = e.execution.task.definition; - if (taskDefinition.type === 'GitHub Local Actions') { - const commandArgs: CommandArgs = taskDefinition.commandArgs; - const historyIndex = taskDefinition.historyIndex; + static getActCommand() { + return ConfigurationManager.get(Section.actCommand) || Act.defaultActCommand; + } - // Add new entry to workspace history - this.historyManager.workspaceHistory[commandArgs.path].push({ - index: historyIndex, - count: taskDefinition.count, - name: `${commandArgs.name}`, - status: HistoryStatus.Running, - date: { - start: taskDefinition.start.toString() - }, - taskExecution: e.execution, - commandArgs: commandArgs, - logPath: taskDefinition.logPath - }); - historyTreeDataProvider.refresh(); - this.storageManager.update(StorageKey.WorkspaceHistory, this.historyManager.workspaceHistory); - } - }); - tasks.onDidEndTaskProcess(e => { - const taskDefinition = e.execution.task.definition; - if (taskDefinition.type === 'GitHub Local Actions') { - const commandArgs: CommandArgs = taskDefinition.commandArgs; - const historyIndex = taskDefinition.historyIndex; + updateActCommand(newActCommand: string) { + const actCommand = ConfigurationManager.get(Section.actCommand); - // Set end status - if (this.historyManager.workspaceHistory[commandArgs.path][historyIndex].status === HistoryStatus.Running) { - if (e.exitCode === 0) { - this.historyManager.workspaceHistory[commandArgs.path][historyIndex].status = HistoryStatus.Success; - } else if (!e.exitCode) { - this.historyManager.workspaceHistory[commandArgs.path][historyIndex].status = HistoryStatus.Cancelled; - } else { - this.historyManager.workspaceHistory[commandArgs.path][historyIndex].status = HistoryStatus.Failed; - } + if (newActCommand !== actCommand) { + window.showInformationMessage(`The act command is currently set to "${actCommand}". Once the installation is complete, it is recommended to update this to "${newActCommand}" for this selected installation method.`, 'Proceed', 'Manually Edit').then(async value => { + if (value === 'Proceed') { + await ConfigurationManager.set(Section.actCommand, newActCommand); + componentsTreeDataProvider.refresh(); + } else if (value === 'Manually Edit') { + await commands.executeCommand('workbench.action.openSettings', ConfigurationManager.getSearchTerm(Section.actCommand)); } - - // Set end time - this.historyManager.workspaceHistory[commandArgs.path][historyIndex].date.end = new Date().toString(); - - historyTreeDataProvider.refresh(); - this.storageManager.update(StorageKey.WorkspaceHistory, this.historyManager.workspaceHistory); - } - }); + }); + } } async runAllWorkflows(workspaceFolder: WorkspaceFolder) { @@ -309,22 +294,21 @@ export class Act { } catch (error: any) { } // Build command with settings + const actCommand = Act.getActCommand(); const settings = await this.settingsManager.getSettings(workspaceFolder, true); const command = - `set -o pipefail; ` + - `${Act.base} ${commandArgs.options}` + + `${actCommand} ${commandArgs.options}` + (settings.secrets.length > 0 ? ` ${Option.Secret} ${settings.secrets.map(secret => secret.key).join(` ${Option.Secret} `)}` : ``) + (settings.secretFiles.length > 0 ? ` ${Option.SecretFile} "${settings.secretFiles[0].path}"` : ` ${Option.SecretFile} ""`) + - (settings.variables.length > 0 ? ` ${Option.Variable} ${settings.variables.map(variable => (variable.value ? `${variable.key}=${variable.value}` : variable.key)).join(` ${Option.Variable} `)}` : ``) + + (settings.variables.length > 0 ? ` ${Option.Variable} ${settings.variables.map(variable => `${variable.key}=${variable.value}`).join(` ${Option.Variable} `)}` : ``) + (settings.variableFiles.length > 0 ? ` ${Option.VariableFile} "${settings.variableFiles[0].path}"` : ` ${Option.VariableFile} ""`) + (settings.inputs.length > 0 ? ` ${Option.Input} ${settings.inputs.map(input => `${input.key}=${input.value}`).join(` ${Option.Input} `)}` : ``) + (settings.inputFiles.length > 0 ? ` ${Option.InputFile} "${settings.inputFiles[0].path}"` : ` ${Option.InputFile} ""`) + (settings.runners.length > 0 ? ` ${Option.Platform} ${settings.runners.map(runner => `${runner.key}=${runner.value}`).join(` ${Option.Platform} `)}` : ``) + - (settings.payloadFiles.length > 0 ? ` ${Option.PayloadFile} "${settings.payloadFiles[0].path}"` : ` ${Option.PayloadFile} ""`) + - ` 2>&1 | tee "${logPath}"`; + (settings.payloadFiles.length > 0 ? ` ${Option.PayloadFile} "${settings.payloadFiles[0].path}"` : ` ${Option.PayloadFile} ""`); // Execute task - await tasks.executeTask({ + const taskExecution = await tasks.executeTask({ name: `${commandArgs.name} #${count}`, detail: `${commandArgs.name} #${count}`, definition: { @@ -350,18 +334,129 @@ export class Act { problemMatchers: [], runOptions: {}, group: TaskGroup.Build, - execution: new ShellExecution( - command, - { - cwd: commandArgs.path, - env: settings.secrets - .filter(secret => secret.value) - .reduce((previousValue, currentValue) => { - previousValue[currentValue.key] = currentValue.value; - return previousValue; - }, {} as Record) + execution: new CustomExecution(async (resolvedDefinition: TaskDefinition): Promise => { + // Add new entry to workspace history + this.historyManager.workspaceHistory[commandArgs.path].push({ + index: historyIndex, + count: count, + name: `${commandArgs.name}`, + status: HistoryStatus.Running, + date: { + start: start.toString() + }, + taskExecution: taskExecution, + commandArgs: commandArgs, + logPath: logPath + }); + historyTreeDataProvider.refresh(); + this.storageManager.update(StorageKey.WorkspaceHistory, this.historyManager.workspaceHistory); + + const writeEmitter = new EventEmitter(); + const closeEmitter = new EventEmitter(); + + writeEmitter.event(async data => { + try { + // Create log file if it does not exist + try { + await fs.access(logPath); + } catch (error: any) { + await fs.writeFile(logPath, ''); + } + + // Append data to log file + await fs.appendFile(logPath, data); + } catch (error) { } + }); + + const handleIO = (data: any) => { + const lines: string[] = data.toString().split('\n').filter((line: string) => line != ''); + for (const line of lines) { + writeEmitter.fire(`${line.trimEnd()}\r\n`); + } } - ) + + let shell = env.shell; + switch (process.platform) { + case Platform.windows: + shell = 'cmd'; + break; + case Platform.mac: + shell = 'zsh'; + break; + case Platform.linux: + shell = 'bash'; + break; + } + + const exec = childProcess.spawn( + command, + { + cwd: commandArgs.path, + shell: shell, + env: { + ...process.env, + ...settings.secrets + .filter(secret => secret.value) + .reduce((previousValue, currentValue) => { + previousValue[currentValue.key] = currentValue.value; + return previousValue; + }, {} as Record) + } + } + ); + exec.stdout.on('data', handleIO); + exec.stderr.on('data', handleIO); + exec.on('exit', (code, signal) => { + // Set execution status and end time in workspace history + if (this.historyManager.workspaceHistory[commandArgs.path][historyIndex].status === HistoryStatus.Running) { + if (code === 0) { + this.historyManager.workspaceHistory[commandArgs.path][historyIndex].status = HistoryStatus.Success; + } else if (!code) { + this.historyManager.workspaceHistory[commandArgs.path][historyIndex].status = HistoryStatus.Cancelled; + } else { + this.historyManager.workspaceHistory[commandArgs.path][historyIndex].status = HistoryStatus.Failed; + } + } + this.historyManager.workspaceHistory[commandArgs.path][historyIndex].date.end = new Date().toString(); + historyTreeDataProvider.refresh(); + this.storageManager.update(StorageKey.WorkspaceHistory, this.historyManager.workspaceHistory); + + if (signal === 'SIGINT') { + writeEmitter.fire(`\r\nTask interrupted.\r\n`); + closeEmitter.fire(code || 1); + } else { + writeEmitter.fire(`\r\nTask exited with exit code ${code}.\r\n`); + closeEmitter.fire(code || 0); + } + }); + exec.on('close', (code) => { + closeEmitter.fire(code || 0); + }); + + return { + onDidWrite: writeEmitter.event, + onDidClose: closeEmitter.event, + open: async (initialDimensions: TerminalDimensions | undefined): Promise => { + writeEmitter.fire(`${command}\r\n\r\n`); + }, + handleInput: (data: string) => { + if (data === '\x03') { + exec.kill('SIGINT'); + exec.stdout.destroy(); + exec.stdin.destroy(); + exec.stderr.destroy(); + } else { + exec.stdin.write(data === '\r' ? '\r\n' : data) + } + }, + close: () => { + exec.kill('SIGINT'); + exec.stdout.destroy(); + exec.stdin.destroy(); + exec.stderr.destroy(); + }, + }; + }) }); this.storageManager.update(StorageKey.WorkspaceHistory, this.historyManager.workspaceHistory); } @@ -372,7 +467,10 @@ export class Act { await tasks.executeTask({ name: 'nektos/act', detail: 'Install nektos/act', - definition: { type: 'nektos/act installation' }, + definition: { + type: 'nektos/act installation', + ghCliInstall: command.includes('gh-act') + }, source: 'GitHub Local Actions', scope: TaskScope.Workspace, isBackground: true, diff --git a/src/componentsManager.ts b/src/componentsManager.ts index d3400e2..1ace6dd 100644 --- a/src/componentsManager.ts +++ b/src/componentsManager.ts @@ -1,5 +1,6 @@ import * as childProcess from "child_process"; import { commands, env, extensions, QuickPickItemKind, ShellExecution, TaskGroup, TaskPanelKind, TaskRevealKind, tasks, TaskScope, ThemeIcon, Uri, window } from "vscode"; +import { Act } from "./act"; import { ConfigurationManager, Platform, Section } from "./configurationManager"; import { act, componentsTreeDataProvider } from "./extension"; import ComponentsTreeDataProvider from "./views/components/componentsTreeDataProvider"; @@ -14,6 +15,7 @@ export interface Component { information: string, installation: () => Promise, start?: () => Promise, + fixPermissions?: () => Promise, message?: string } @@ -21,7 +23,8 @@ export enum CliStatus { Installed = 'Installed', NotInstalled = 'Not Installed', Running = 'Running', - NotRunning = 'Not Running' + NotRunning = 'Not Running', + InvalidPermissions = 'Invalid Permissions' } export enum ExtensionStatus { @@ -30,10 +33,13 @@ export enum ExtensionStatus { } export class ComponentsManager { + static actVersionRegExp: RegExp = /act version (.+)/; + static dockerVersionRegExp: RegExp = /Docker Engine Version:\s(.+)/; + async getComponents(): Promise[]> { const components: Component[] = []; - const actCliInfo = await this.getCliInfo('act --version', /act version (.+)/, false, false); + const actCliInfo = await this.getCliInfo(`${Act.getActCommand()} --version`, ComponentsManager.actVersionRegExp, false, false); components.push({ name: 'nektos/act', icon: 'terminal', @@ -103,19 +109,23 @@ export class ComponentsManager { if (selectedPrebuiltExecutable) { await env.openExternal(Uri.parse(selectedPrebuiltExecutable.link)); - window.showInformationMessage('Unpack and run the executable in the terminal specifying the full path or add it to one of the paths in your PATH environment variable. Once nektos/act is successfully installed, refresh the components view.', 'Refresh').then(async value => { + window.showInformationMessage('Unpack the executable and move it to your desired location. Once nektos/act is successfully installed, add it to your shell\'s PATH and then refresh the components view.', 'Refresh').then(async value => { if (value === 'Refresh') { componentsTreeDataProvider.refresh(); } }); } + + act.updateActCommand(Act.defaultActCommand); } else if (selectedInstallationMethod.link) { await env.openExternal(Uri.parse(selectedInstallationMethod.link)); - window.showInformationMessage('Once nektos/act is successfully installed, refresh the components view.', 'Refresh').then(async value => { + window.showInformationMessage('Once nektos/act is successfully installed, add it to your shell\'s PATH and then refresh the components view.', 'Refresh').then(async value => { if (value === 'Refresh') { componentsTreeDataProvider.refresh(); } }); + + act.updateActCommand(Act.defaultActCommand); } else { await act.install(selectedInstallationMethod.label); } @@ -123,7 +133,7 @@ export class ComponentsManager { } }); - const dockerCliInfo = await this.getCliInfo('docker version', /Client:\n.+\n\sVersion:\s+(.+)/, true, true); + const dockerCliInfo = await this.getCliInfo(`docker version --format "Docker Engine Version: {{.Client.Version}}"`, ComponentsManager.dockerVersionRegExp, true, true); const dockerDesktopPath = ConfigurationManager.get(Section.dockerDesktopPath); components.push({ name: 'Docker Engine', @@ -145,7 +155,9 @@ export class ComponentsManager { await tasks.executeTask({ name: 'Docker Engine', detail: 'Start Docker Engine', - definition: { type: 'GitHub Local Actions' }, + definition: { + type: 'Start Docker Engine' + }, source: 'GitHub Local Actions', scope: TaskScope.Workspace, isBackground: true, @@ -161,7 +173,7 @@ export class ComponentsManager { problemMatchers: [], runOptions: {}, group: TaskGroup.Build, - execution: new ShellExecution('sudo dockerd') + execution: new ShellExecution('systemctl start docker', { executable: env.shell }) }); } else { window.showErrorMessage(`Invalid environment: ${process.platform}`, 'Report an Issue').then(async value => { @@ -179,7 +191,7 @@ export class ComponentsManager { await delay(4000); // Check again for docker status - const newDockerCliInfo = await this.getCliInfo('docker version', /Client:\n.+\n\sVersion:\s+(.+)/, true, true); + const newDockerCliInfo = await this.getCliInfo(`docker version --format "Docker Engine Version: {{.Client.Version}}"`, ComponentsManager.dockerVersionRegExp, true, true); if (dockerCliInfo.status !== newDockerCliInfo.status) { componentsTreeDataProvider.refresh(); } else { @@ -198,6 +210,55 @@ export class ComponentsManager { }); } }); + }, + fixPermissions: async () => { + if (process.platform === Platform.linux) { + window.showInformationMessage('By default, the Docker daemon binds to a Unix socket owned by the root user. To manage Docker as a non-root user, a Unix group called "docker" should be created with your user added to it.', 'Proceed', 'Learn More').then(async value => { + if (value === 'Proceed') { + await tasks.executeTask({ + name: 'Docker Engine', + detail: 'Fix Docker Engine Permissions', + definition: { + type: 'Fix Docker Engine Permissions' + }, + source: 'GitHub Local Actions', + scope: TaskScope.Workspace, + isBackground: true, + presentationOptions: { + reveal: TaskRevealKind.Always, + focus: false, + clear: true, + close: false, + echo: true, + panel: TaskPanelKind.Shared, + showReuseMessage: false + }, + problemMatchers: [], + runOptions: {}, + group: TaskGroup.Build, + execution: new ShellExecution('sudo groupadd docker; sudo usermod -aG docker $USER') + }); + + window.withProgress({ location: { viewId: ComponentsTreeDataProvider.VIEW_ID } }, async () => { + // Delay 4 seconds for Docker to be started + const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + await delay(4000); + + // Check again for docker status + const newDockerCliInfo = await this.getCliInfo(`docker version --format "Docker Engine Version: {{.Client.Version}}"`, ComponentsManager.dockerVersionRegExp, true, true); + if (dockerCliInfo.status !== newDockerCliInfo.status) { + componentsTreeDataProvider.refresh(); + } else { + window.showInformationMessage('You may need to restart your PC for these changes to take affect.'); + } + }); + } else if (value === 'Learn More') { + await env.openExternal(Uri.parse('https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user')); + } + }); + } else { + window.showErrorMessage(`Permissions cannot be automatically fixed for ${process.platform} environment.`); + } } }); @@ -234,7 +295,7 @@ export class ComponentsManager { async getUnreadyComponents(): Promise[]> { const components = await this.getComponents(); - return components.filter(component => component.required && [CliStatus.NotInstalled, CliStatus.NotRunning, ExtensionStatus.NotActivated].includes(component.status)); + return components.filter(component => component.required && [CliStatus.NotInstalled, CliStatus.NotRunning, CliStatus.InvalidPermissions, ExtensionStatus.NotActivated].includes(component.status)); } async getCliInfo(command: string, versionRegex: RegExp, ignoreError: boolean, checksIfRunning: boolean): Promise<{ version?: string, status: CliStatus }> { @@ -246,7 +307,9 @@ export class ComponentsManager { if (ignoreError && version) { resolve({ version: version[1], - status: CliStatus.NotRunning + status: (process.platform === Platform.linux && error.message.toLowerCase().includes('permission denied')) ? + CliStatus.InvalidPermissions : + CliStatus.NotRunning }); } else { resolve({ diff --git a/src/configurationManager.ts b/src/configurationManager.ts index 889271f..578cd41 100644 --- a/src/configurationManager.ts +++ b/src/configurationManager.ts @@ -1,6 +1,7 @@ import * as os from "os"; import * as path from "path"; import { ConfigurationTarget, workspace } from 'vscode'; +import { Act } from './act'; export enum Platform { windows = 'win32', @@ -9,6 +10,7 @@ export enum Platform { } export enum Section { + actCommand = 'actCommand', dockerDesktopPath = 'dockerDesktopPath', actionCachePath = 'actionCachePath' } @@ -17,7 +19,7 @@ export namespace ConfigurationManager { export const group: string = 'githubLocalActions'; export const searchPrefix: string = '@ext:sanjulaganepola.github-local-actions'; - export function initialize(): void { + export async function initialize(): Promise { let dockerDesktopPath = ConfigurationManager.get(Section.dockerDesktopPath); if (!dockerDesktopPath) { switch (process.platform) { @@ -31,7 +33,12 @@ export namespace ConfigurationManager { return; } - ConfigurationManager.set(Section.dockerDesktopPath, dockerDesktopPath); + await ConfigurationManager.set(Section.dockerDesktopPath, dockerDesktopPath); + } + + let actCommand = ConfigurationManager.get(Section.actCommand); + if (!actCommand) { + await ConfigurationManager.set(Section.actCommand, Act.defaultActCommand); } let actionCachePath = ConfigurationManager.get(Section.actionCachePath); diff --git a/src/extension.ts b/src/extension.ts index c39afb8..7aa315c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -54,7 +54,8 @@ export function activate(context: vscode.ExtensionContext) { ConfigurationManager.initialize(); workspace.onDidChangeConfiguration(async event => { if (event.affectsConfiguration(ConfigurationManager.group)) { - ConfigurationManager.initialize(); + await ConfigurationManager.initialize(); + componentsTreeDataProvider.refresh(); } }); diff --git a/src/settingsManager.ts b/src/settingsManager.ts index ce85848..ea4268c 100644 --- a/src/settingsManager.ts +++ b/src/settingsManager.ts @@ -38,7 +38,8 @@ export enum Visibility { export enum SettingFileName { secretFile = '.secrets', - variableFile = '.env', + envFile = '.env', + variableFile = '.vars', inputFile = '.input', payloadFile = 'payload.json' } @@ -59,9 +60,9 @@ export class SettingsManager { } async getSettings(workspaceFolder: WorkspaceFolder, isUserSelected: boolean): Promise { - const secrets = (await this.getSetting(workspaceFolder, SettingsManager.secretsRegExp, StorageKey.Secrets, true, Visibility.hide)).filter(secret => !isUserSelected || secret.selected); + const secrets = (await this.getSetting(workspaceFolder, SettingsManager.secretsRegExp, StorageKey.Secrets, true, Visibility.hide)).filter(secret => !isUserSelected || (secret.selected && secret.value)); const secretFiles = (await this.getSettingFiles(workspaceFolder, StorageKey.SecretFiles)).filter(secretFile => !isUserSelected || secretFile.selected); - const variables = (await this.getSetting(workspaceFolder, SettingsManager.variablesRegExp, StorageKey.Variables, false, Visibility.show)).filter(variable => !isUserSelected || variable.selected); + const variables = (await this.getSetting(workspaceFolder, SettingsManager.variablesRegExp, StorageKey.Variables, false, Visibility.show)).filter(variable => !isUserSelected || (variable.selected && variable.value)); const variableFiles = (await this.getSettingFiles(workspaceFolder, StorageKey.VariableFiles)).filter(variableFile => !isUserSelected || variableFile.selected); const inputs = (await this.getSetting(workspaceFolder, SettingsManager.inputsRegExp, StorageKey.Inputs, false, Visibility.show)).filter(input => !isUserSelected || (input.selected && input.value)); const inputFiles = (await this.getSettingFiles(workspaceFolder, StorageKey.InputFiles)).filter(inputFile => !isUserSelected || inputFile.selected); @@ -164,7 +165,7 @@ export class SettingsManager { return environments; } - async createSettingFile(workspaceFolder: WorkspaceFolder, storageKey: StorageKey, settingFileName: string) { + async createSettingFile(workspaceFolder: WorkspaceFolder, storageKey: StorageKey, settingFileName: string, content: string) { const settingFileUri = Uri.file(path.join(workspaceFolder.uri.fsPath, settingFileName)); try { @@ -172,7 +173,7 @@ export class SettingsManager { window.showErrorMessage(`A file or folder named ${settingFileName} already exists at ${workspaceFolder.uri.fsPath}. Please choose another name.`); } catch (error: any) { try { - await workspace.fs.writeFile(settingFileUri, new TextEncoder().encode('')); + await workspace.fs.writeFile(settingFileUri, new TextEncoder().encode(content)); await this.locateSettingFile(workspaceFolder, storageKey, [settingFileUri]); const document = await workspace.openTextDocument(settingFileUri); await window.showTextDocument(document); diff --git a/src/views/components/componentsTreeDataProvider.ts b/src/views/components/componentsTreeDataProvider.ts index 725f931..99f5413 100644 --- a/src/views/components/componentsTreeDataProvider.ts +++ b/src/views/components/componentsTreeDataProvider.ts @@ -27,9 +27,15 @@ export default class ComponentsTreeDataProvider implements TreeDataProvider { + const fixPermissions = componentTreeItem.component.fixPermissions; + if (fixPermissions) { + await fixPermissions(); + this.refresh(); } - - this.refresh(); }) ); } diff --git a/src/views/decorationProvider.ts b/src/views/decorationProvider.ts index a34b56e..5654579 100644 --- a/src/views/decorationProvider.ts +++ b/src/views/decorationProvider.ts @@ -1,6 +1,11 @@ import { CancellationToken, Event, FileDecoration, FileDecorationProvider, ProviderResult, ThemeColor, Uri } from "vscode"; import { CliStatus, ExtensionStatus } from "../componentsManager"; import ComponentTreeItem from "./components/component"; +import InputsTreeItem from "./settings/inputs"; +import RunnersTreeItem from "./settings/runners"; +import SecretsTreeItem from "./settings/secrets"; +import { SettingContextValue } from "./settings/setting"; +import VariablesTreeItem from "./settings/variables"; import WorkflowTreeItem from "./workflows/workflow"; export class DecorationProvider implements FileDecorationProvider { @@ -17,12 +22,12 @@ export class DecorationProvider implements FileDecorationProvider { badge: '✅', color: new ThemeColor('GitHubLocalActions.green') }; - } else if (!required && (status === CliStatus.NotInstalled || status === CliStatus.NotRunning || status === ExtensionStatus.NotActivated)) { + } else if (!required && (status === CliStatus.NotInstalled || status === CliStatus.NotRunning || status === CliStatus.InvalidPermissions || status === ExtensionStatus.NotActivated)) { return { badge: '⚠️', color: new ThemeColor('GitHubLocalActions.yellow') }; - } else if (required && (status === CliStatus.NotInstalled || status === CliStatus.NotRunning || status === ExtensionStatus.NotActivated)) { + } else if (required && (status === CliStatus.NotInstalled || status === CliStatus.NotRunning || status === CliStatus.InvalidPermissions || status === ExtensionStatus.NotActivated)) { return { badge: '❌', color: new ThemeColor('GitHubLocalActions.red') @@ -37,14 +42,24 @@ export class DecorationProvider implements FileDecorationProvider { color: new ThemeColor('GitHubLocalActions.red') }; } + } else if ([SecretsTreeItem.contextValue, VariablesTreeItem.contextValue, InputsTreeItem.contextValue, RunnersTreeItem.contextValue].includes(uri.scheme)) { + const hasAllValues = params.get('hasAllValues') === 'true'; + + if (!hasAllValues) { + return { + color: new ThemeColor('GitHubLocalActions.red') + }; + } + } else if (Object.values(SettingContextValue).includes(uri.scheme as any)) { + const isSelected = params.get('isSelected') === 'true'; + const hasValue = params.get('hasValue') === 'true'; + + if (isSelected && !hasValue) { + return { + badge: '?', + color: new ThemeColor('GitHubLocalActions.red') + }; + } } - - // else if (uri.scheme === SecretsTreeItem.contextValue || uri.scheme === VariablesTreeItem.contextValue || uri.scheme === InputsTreeItem.contextValue || uri.scheme === RunnersTreeItem.contextValue) { - // const selected = params.get('selected'); - - // return { - // badge: `${selected}` - // }; - // } } } \ No newline at end of file diff --git a/src/views/settings/inputs.ts b/src/views/settings/inputs.ts index 6c1deb9..d4bf749 100644 --- a/src/views/settings/inputs.ts +++ b/src/views/settings/inputs.ts @@ -1,4 +1,4 @@ -import { ThemeIcon, TreeItem, TreeItemCollapsibleState, WorkspaceFolder } from "vscode"; +import { ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, WorkspaceFolder } from "vscode"; import { act } from "../../extension"; import { Setting, SettingFile } from "../../settingsManager"; import { StorageKey } from "../../storageManager"; @@ -17,6 +17,8 @@ export default class InputsTreeItem extends TreeItem implements GithubLocalActio (selectedInputFiles.length > 0 ? ` + ${selectedInputFiles[0].name}` : ``); this.contextValue = InputsTreeItem.contextValue; this.iconPath = new ThemeIcon('record-keys'); + const hasAllValues = inputs.filter(input => input.selected && input.value === '').length === 0; + this.resourceUri = Uri.parse(`${InputsTreeItem.contextValue}:Inputs?hasAllValues=${hasAllValues}`, true); } async getChildren(): Promise { diff --git a/src/views/settings/runners.ts b/src/views/settings/runners.ts index e345410..31aea9d 100644 --- a/src/views/settings/runners.ts +++ b/src/views/settings/runners.ts @@ -1,4 +1,4 @@ -import { ThemeIcon, TreeItem, TreeItemCollapsibleState, WorkspaceFolder } from "vscode"; +import { ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, WorkspaceFolder } from "vscode"; import { act } from "../../extension"; import { Setting } from "../../settingsManager"; import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem"; @@ -12,6 +12,8 @@ export default class RunnersTreeItem extends TreeItem implements GithubLocalActi this.description = `${runners.filter(runner => runner.selected).length}/${runners.length}`; this.contextValue = RunnersTreeItem.contextValue; this.iconPath = new ThemeIcon('server-environment'); + const hasAllValues = runners.filter(runner => runner.selected && runner.value === '').length === 0; + this.resourceUri = Uri.parse(`${RunnersTreeItem.contextValue}:Runners?hasAllValues=${hasAllValues}`, true); } async getChildren(): Promise { diff --git a/src/views/settings/secrets.ts b/src/views/settings/secrets.ts index 8259532..3bfda9a 100644 --- a/src/views/settings/secrets.ts +++ b/src/views/settings/secrets.ts @@ -1,4 +1,4 @@ -import { ThemeIcon, TreeItem, TreeItemCollapsibleState, WorkspaceFolder } from "vscode"; +import { ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, WorkspaceFolder } from "vscode"; import { act } from "../../extension"; import { Setting, SettingFile } from "../../settingsManager"; import { StorageKey } from "../../storageManager"; @@ -17,6 +17,8 @@ export default class SecretsTreeItem extends TreeItem implements GithubLocalActi (selectedSecretFiles.length > 0 ? ` + ${selectedSecretFiles[0].name}` : ``); this.contextValue = SecretsTreeItem.contextValue; this.iconPath = new ThemeIcon('lock'); + const hasAllValues = secrets.filter(secret => secret.selected && secret.value === '').length === 0; + this.resourceUri = Uri.parse(`${SecretsTreeItem.contextValue}:Secrets?hasAllValues=${hasAllValues}`, true); } async getChildren(): Promise { diff --git a/src/views/settings/setting.ts b/src/views/settings/setting.ts index cbdaea6..45df970 100644 --- a/src/views/settings/setting.ts +++ b/src/views/settings/setting.ts @@ -1,8 +1,15 @@ -import { ThemeIcon, TreeItem, TreeItemCheckboxState, TreeItemCollapsibleState, WorkspaceFolder } from "vscode"; +import { ThemeIcon, TreeItem, TreeItemCheckboxState, TreeItemCollapsibleState, Uri, WorkspaceFolder } from "vscode"; import { Setting, Visibility } from "../../settingsManager"; import { StorageKey } from "../../storageManager"; import { GithubLocalActionsTreeItem } from "../githubLocalActionsTreeItem"; +export enum SettingContextValue { + secret = 'githubLocalActions.secret', + variable = 'githubLocalActions.variable', + input = 'githubLocalActions.input', + runner = 'githubLocalActions.runner' +} + export default class SettingTreeItem extends TreeItem implements GithubLocalActionsTreeItem { setting: Setting; storageKey: StorageKey; @@ -19,6 +26,7 @@ export default class SettingTreeItem extends TreeItem implements GithubLocalActi this.contextValue = `${treeItem.contextValue}_${setting.password ? setting.visible : ''}`; this.iconPath = treeItem.iconPath; this.checkboxState = setting.selected ? TreeItemCheckboxState.Checked : TreeItemCheckboxState.Unchecked; + this.resourceUri = Uri.parse(`${treeItem.contextValue}:${setting.key}?isSelected=${setting.selected}&hasValue=${setting.value !== ''}`, true); } static getSecretTreeItem(workspaceFolder: WorkspaceFolder, secret: Setting): SettingTreeItem { @@ -27,7 +35,7 @@ export default class SettingTreeItem extends TreeItem implements GithubLocalActi secret, StorageKey.Secrets, { - contextValue: 'githubLocalActions.secret', + contextValue: SettingContextValue.secret, iconPath: new ThemeIcon('key') } ); @@ -39,7 +47,7 @@ export default class SettingTreeItem extends TreeItem implements GithubLocalActi variable, StorageKey.Variables, { - contextValue: 'githubLocalActions.variable', + contextValue: SettingContextValue.variable, iconPath: new ThemeIcon('symbol-variable') } ); @@ -51,7 +59,7 @@ export default class SettingTreeItem extends TreeItem implements GithubLocalActi input, StorageKey.Inputs, { - contextValue: 'githubLocalActions.input', + contextValue: SettingContextValue.input, iconPath: new ThemeIcon('symbol-parameter') } ); @@ -63,7 +71,7 @@ export default class SettingTreeItem extends TreeItem implements GithubLocalActi runner, StorageKey.Runners, { - contextValue: 'githubLocalActions.runner', + contextValue: SettingContextValue.runner, iconPath: new ThemeIcon('vm-connect') } ); diff --git a/src/views/settings/settingsTreeDataProvider.ts b/src/views/settings/settingsTreeDataProvider.ts index 8a90368..73ec5da 100644 --- a/src/views/settings/settingsTreeDataProvider.ts +++ b/src/views/settings/settingsTreeDataProvider.ts @@ -29,7 +29,7 @@ export default class SettingsTreeDataProvider implements TreeDataProvider 0 ? ` + ${selectedVariableFiles[0].name}` : ``); this.contextValue = VariablesTreeItem.contextValue; this.iconPath = new ThemeIcon('symbol-key'); + const hasAllValues = variables.filter(variable => variable.selected && variable.value === '').length === 0; + this.resourceUri = Uri.parse(`${VariablesTreeItem.contextValue}:Variables?hasAllValues=${hasAllValues}`, true); } async getChildren(): Promise {